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 { 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 { 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 { 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 = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; }