T-HUB/apps/api/src/groups/groups.service.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

472 lines
18 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { scrub, type AuditEvent } from "../audit/audit.service.js";
import { issueSessionInTenant } from "../auth/auth.service.js";
import { withTenant } from "../db/client.js";
/**
* Groups: sub-workspace sharing scopes. Owner/Admin publish through every
* connection; everyone else publishes only through connections assigned to a
* group they belong to.
*
* Every mutation writes its audit row on the SAME transaction (audit_append on
* the tenant client), so a change can never land without its audit entry.
*
* Group-membership changes bump the workspace authz epoch (008 trigger), which
* invalidates all live sessions — including the caller's. Those mutations return
* a fresh token for the caller, minted inside the same transaction (same
* contract as TeamService).
*/
export const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
export function hasWorkspaceWideAccess(role: string): boolean {
return WORKSPACE_WIDE_ROLES.includes(role);
}
export interface GroupActor {
userId: string;
role: string;
}
export interface GroupSummary {
id: string;
name: string;
description: string;
memberCount: number;
connectionCount: number;
assetCount: number;
createdAt: string;
}
export interface GroupMember {
userId: string;
email: string;
displayName: string;
addedBy: string | null;
addedAt: string;
}
export interface GroupConnection {
connectionId: string;
provider: string;
channelId: string;
assignedBy: string | null;
assignedAt: string;
}
export interface GroupAsset {
assetId: string;
assignedBy: string | null;
assignedAt: string;
}
export interface GroupDetail extends GroupSummary {
members: GroupMember[];
connections: GroupConnection[];
assets: GroupAsset[];
}
/** Result of a mutation that bumps the authz epoch: the caller's replacement session. */
export interface GroupMembershipResult {
token: string;
}
export interface VisibleConnections {
/** "workspace": Owner/Admin — every connection. "groups": union of the user's groups. */
scope: "workspace" | "groups";
connectionIds: string[];
}
interface GroupRow {
id: string;
name: string;
description: string;
created_at: Date;
member_count: string | number;
connection_count: string | number;
asset_count: string | number;
}
const GROUP_SELECT = `SELECT g.id, g.name, g.description, g.created_at,
(SELECT count(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count,
(SELECT count(*) FROM group_connections gc WHERE gc.group_id = g.id) AS connection_count,
(SELECT count(*) FROM group_assets ga WHERE ga.group_id = g.id) AS asset_count
FROM groups g`;
function summaryFromRow(row: GroupRow): GroupSummary {
return {
id: row.id,
name: row.name,
description: row.description,
memberCount: Number(row.member_count),
connectionCount: Number(row.connection_count),
assetCount: Number(row.asset_count),
createdAt: new Date(row.created_at).toISOString(),
};
}
function iso(value: Date): string {
return new Date(value).toISOString();
}
/** node-postgres surfaces SQLSTATE on `code`. */
function pgCode(err: unknown): string | undefined {
if (typeof err !== "object" || err === null || !("code" in err)) return undefined;
const code = (err as { code: unknown }).code;
return typeof code === "string" ? code : undefined;
}
const UNIQUE_VIOLATION = "23505";
const FK_VIOLATION = "23503";
async function audit(client: PoolClient, workspaceId: string, event: AuditEvent): Promise<void> {
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
workspaceId,
event.actorUserId,
event.actorRole,
event.action,
event.targetType,
event.targetId,
scrub(event.diff ?? null),
]);
}
@Injectable()
export class GroupsService {
async createGroup(workspaceId: string, name: string, description: string, actor: GroupActor): Promise<GroupSummary> {
const cleanName = name.trim();
if (cleanName === "") throw new BadRequestException("name must be a non-empty string");
return withTenant(workspaceId, async (client) => {
let id: string;
try {
const res = await client.query(
"INSERT INTO groups (workspace_id, name, description) VALUES ($1, $2, $3) RETURNING id",
[workspaceId, cleanName, description],
);
id = res.rows[0]?.id as string;
} catch (err) {
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${cleanName}" already exists`);
throw err;
}
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.created",
targetType: "group",
targetId: id,
diff: { name: cleanName, description },
});
return this.summary(client, id);
});
}
async renameGroup(
workspaceId: string,
groupId: string,
changes: { name?: string; description?: string },
actor: GroupActor,
): Promise<GroupSummary> {
const name = changes.name?.trim();
if (name === "") throw new BadRequestException("name must be a non-empty string");
if (name === undefined && changes.description === undefined) {
throw new BadRequestException("nothing to update: pass name and/or description");
}
return withTenant(workspaceId, async (client) => {
const before = await this.summary(client, groupId, true);
try {
await client.query(
"UPDATE groups SET name = COALESCE($2, name), description = COALESCE($3, description) WHERE id = $1",
[groupId, name ?? null, changes.description ?? null],
);
} catch (err) {
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${name ?? ""}" already exists`);
throw err;
}
const after = await this.summary(client, groupId);
const diff: Record<string, unknown> = {};
if (after.name !== before.name) diff.name = [before.name, after.name];
if (after.description !== before.description) diff.description = [before.description, after.description];
if (Object.keys(diff).length > 0) {
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.renamed",
targetType: "group",
targetId: groupId,
diff,
});
}
return after;
});
}
/** Cascades to members/connections/assets; member rows going away bump the epoch. */
async deleteGroup(workspaceId: string, groupId: string, actor: GroupActor): Promise<GroupMembershipResult> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const before = await this.summary(client, groupId, true);
await client.query("DELETE FROM groups WHERE id = $1", [groupId]);
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.deleted",
targetType: "group",
targetId: groupId,
diff: {
name: before.name,
memberCount: before.memberCount,
connectionCount: before.connectionCount,
assetCount: before.assetCount,
},
});
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
});
}
async addMember(workspaceId: string, groupId: string, userId: string, actor: GroupActor): Promise<GroupMembershipResult> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await this.summary(client, groupId);
let inserted: number;
try {
const res = await client.query(
`INSERT INTO group_members (workspace_id, group_id, user_id, added_by) VALUES ($1, $2, $3, $4)
ON CONFLICT (group_id, user_id) DO NOTHING`,
[workspaceId, groupId, userId, actor.userId],
);
inserted = res.rowCount ?? 0;
} catch (err) {
// Composite FK to memberships: the user is not a member of this workspace.
if (pgCode(err) === FK_VIOLATION) throw new BadRequestException("user is not a member of this workspace");
throw err;
}
if (inserted === 0) throw new ConflictException("user is already a member of this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.member_added",
targetType: "group",
targetId: groupId,
diff: { userId },
});
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
});
}
async removeMember(workspaceId: string, groupId: string, userId: string, actor: GroupActor): Promise<GroupMembershipResult> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const res = await client.query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2", [groupId, userId]);
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("user is not a member of this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.member_removed",
targetType: "group",
targetId: groupId,
diff: { userId },
});
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
});
}
async assignConnection(workspaceId: string, groupId: string, connectionId: string, actor: GroupActor): Promise<GroupSummary & GroupMembershipResult> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await this.summary(client, groupId);
let inserted: number;
try {
const res = await client.query(
`INSERT INTO group_connections (workspace_id, group_id, connection_id, assigned_by) VALUES ($1, $2, $3, $4)
ON CONFLICT (group_id, connection_id) DO NOTHING`,
[workspaceId, groupId, connectionId, actor.userId],
);
inserted = res.rowCount ?? 0;
} catch (err) {
if (pgCode(err) === FK_VIOLATION) throw new NotFoundException(`connection ${connectionId} not found`);
throw err;
}
if (inserted === 0) throw new ConflictException("connection is already assigned to this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.connection_assigned",
targetType: "group",
targetId: groupId,
diff: { connectionId },
});
return { ...await this.summary(client, groupId), token: await issueSessionInTenant(client, workspaceId, actor.userId) };
});
}
async unassignConnection(workspaceId: string, groupId: string, connectionId: string, actor: GroupActor): Promise<GroupSummary & GroupMembershipResult> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const res = await client.query("DELETE FROM group_connections WHERE group_id = $1 AND connection_id = $2", [
groupId,
connectionId,
]);
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("connection is not assigned to this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.connection_unassigned",
targetType: "group",
targetId: groupId,
diff: { connectionId },
});
return { ...await this.summary(client, groupId), token: await issueSessionInTenant(client, workspaceId, actor.userId) };
});
}
async assignAsset(workspaceId: string, groupId: string, assetId: string, actor: GroupActor): Promise<GroupSummary> {
const cleanAssetId = assetId.trim();
if (cleanAssetId === "") throw new BadRequestException("assetId must be a non-empty string");
return withTenant(workspaceId, async (client) => {
await this.summary(client, groupId);
const res = await client.query(
`INSERT INTO group_assets (workspace_id, group_id, asset_id, assigned_by) VALUES ($1, $2, $3, $4)
ON CONFLICT (group_id, asset_id) DO NOTHING`,
[workspaceId, groupId, cleanAssetId, actor.userId],
);
if ((res.rowCount ?? 0) === 0) throw new ConflictException("asset is already assigned to this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.asset_assigned",
targetType: "group",
targetId: groupId,
diff: { assetId: cleanAssetId },
});
return this.summary(client, groupId);
});
}
async unassignAsset(workspaceId: string, groupId: string, assetId: string, actor: GroupActor): Promise<GroupSummary> {
return withTenant(workspaceId, async (client) => {
const res = await client.query("DELETE FROM group_assets WHERE group_id = $1 AND asset_id = $2", [groupId, assetId]);
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("asset is not assigned to this group");
await audit(client, workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "group.asset_unassigned",
targetType: "group",
targetId: groupId,
diff: { assetId },
});
return this.summary(client, groupId);
});
}
async listGroups(workspaceId: string): Promise<GroupSummary[]> {
const res = await withTenant(workspaceId, (client) => client.query(`${GROUP_SELECT} ORDER BY g.name ASC`));
return (res.rows as GroupRow[]).map(summaryFromRow);
}
async getGroupDetail(workspaceId: string, groupId: string): Promise<GroupDetail> {
return withTenant(workspaceId, async (client) => {
const summary = await this.summary(client, groupId);
const members = await client.query(
`SELECT gm.user_id, u.email, u.display_name, gm.added_by, gm.created_at
FROM group_members gm JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1 ORDER BY gm.created_at ASC`,
[groupId],
);
const connections = await client.query(
`SELECT gc.connection_id, c.provider, c.channel_id, gc.assigned_by, gc.created_at
FROM group_connections gc JOIN connections c ON c.id = gc.connection_id
WHERE gc.group_id = $1 ORDER BY gc.created_at ASC`,
[groupId],
);
const assets = await client.query(
"SELECT asset_id, assigned_by, created_at FROM group_assets WHERE group_id = $1 ORDER BY created_at ASC",
[groupId],
);
return {
...summary,
members: (
members.rows as Array<{ user_id: string; email: string; display_name: string; added_by: string | null; created_at: Date }>
).map((r) => ({ userId: r.user_id, email: r.email, displayName: r.display_name, addedBy: r.added_by, addedAt: iso(r.created_at) })),
connections: (
connections.rows as Array<{ connection_id: string; provider: string; channel_id: string; assigned_by: string | null; created_at: Date }>
).map((r) => ({
connectionId: r.connection_id,
provider: r.provider,
channelId: r.channel_id,
assignedBy: r.assigned_by,
assignedAt: iso(r.created_at),
})),
assets: (assets.rows as Array<{ asset_id: string; assigned_by: string | null; created_at: Date }>).map((r) => ({
assetId: r.asset_id,
assignedBy: r.assigned_by,
assignedAt: iso(r.created_at),
})),
};
});
}
async getUserGroups(workspaceId: string, userId: string): Promise<GroupSummary[]> {
const res = await withTenant(workspaceId, (client) =>
client.query(
`${GROUP_SELECT} WHERE EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1)
ORDER BY g.name ASC`,
[userId],
),
);
return (res.rows as GroupRow[]).map(summaryFromRow);
}
async getVisibleConnections(workspaceId: string, userId: string, isOwnerOrAdmin: boolean): Promise<VisibleConnections> {
return withTenant(workspaceId, async (client) => {
if (isOwnerOrAdmin) {
const res = await client.query("SELECT id FROM connections ORDER BY created_at ASC");
return { scope: "workspace", connectionIds: (res.rows as Array<{ id: string }>).map((r) => r.id) };
}
const res = await client.query(
`SELECT c.id FROM connections c
WHERE EXISTS (
SELECT 1 FROM group_connections gc
JOIN group_members gm ON gm.group_id = gc.group_id
WHERE gc.connection_id = c.id AND gm.user_id = $1
)
ORDER BY c.created_at ASC`,
[userId],
);
return { scope: "groups", connectionIds: (res.rows as Array<{ id: string }>).map((r) => r.id) };
});
}
private async summary(client: PoolClient, groupId: string, lock = false): Promise<GroupSummary> {
const res = await client.query(`${GROUP_SELECT} WHERE g.id = $1${lock ? " FOR UPDATE OF g" : ""}`, [groupId]);
const row = res.rows[0] as GroupRow | undefined;
if (row === undefined) throw new NotFoundException(`group ${groupId} not found`);
return summaryFromRow(row);
}
}
/**
* Guard-side check (same tenant client): can this user publish through this
* connection? Role is read from the DB, not the token, so it is the live value.
*/
export async function userCanUseConnection(
client: PoolClient,
workspaceId: string,
userId: string,
connectionId: string,
): Promise<boolean> {
const res = await client.query(
`SELECT 1 FROM memberships m
WHERE m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'
AND (
m.role = ANY($4::text[])
OR EXISTS (
SELECT 1 FROM group_connections gc
JOIN group_members gm ON gm.group_id = gc.group_id
WHERE gc.connection_id = $3 AND gm.user_id = m.user_id
)
)`,
[workspaceId, userId, connectionId, WORKSPACE_WIDE_ROLES],
);
return (res.rowCount ?? 0) > 0;
}