import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; import type { PoolClient } from "pg"; import { randomBytes } from "node:crypto"; import { appendTenantAudit } from "../audit/audit.service.js"; import { issueSessionInTenant, localExternalId } from "../auth/auth.service.js"; import { pool, withTenant } from "../db/client.js"; export type MemberStatus = "active" | "invited"; export interface Member { id: string; userId: string; username: string; email: string | null; displayName: string; role: string; status: MemberStatus; createdAt: string; } /** * Every membership mutation bumps the workspace authz epoch (DB trigger), which * invalidates ALL live sessions — including the caller's. Mutations therefore * return a fresh token for the caller, minted inside the same transaction from * the post-change membership row, so the acting admin is not signed out. */ export interface MemberMutationResult { member: Member | null; token: string | null; } export interface TeamActor { userId: string; role: string; } const OWNER = "Owner"; interface MemberRow { id: string; user_id: string; username: string; email: string | null; display_name: string; role: string; status: MemberStatus; created_at: Date; } function memberFromRow(row: MemberRow): Member { return { id: row.id, userId: row.user_id, username: row.username, email: row.email, displayName: row.display_name, role: row.role, status: row.status, createdAt: new Date(row.created_at).toISOString(), }; } const MEMBER_SELECT = `SELECT m.id, m.user_id, u.username, u.email, u.display_name, m.role, m.status, m.created_at FROM memberships m JOIN users u ON u.id = m.user_id`; /** Placeholder username derived from the invite email's local-part, with a uniqueness fallback. */ function usernameFromEmail(email: string): string { return (email.split("@")[0] ?? "member").toLowerCase().replace(/[^a-z0-9_.-]/g, "").slice(0, 32) || "member"; } @Injectable() export class TeamService { async list(workspaceId: string): Promise { const res = await withTenant(workspaceId, (client) => client.query(`${MEMBER_SELECT} ORDER BY m.created_at ASC`)); return (res.rows as MemberRow[]).map(memberFromRow); } async roles(): Promise { // role_permissions is global reference data (no RLS). const res = await pool.query("SELECT DISTINCT role FROM role_permissions ORDER BY role"); return (res.rows as Array<{ role: string }>).map((r) => r.role); } async invite(workspaceId: string, actor: TeamActor, email: string, role: string): Promise { const result = await withTenant(workspaceId, async (client) => { await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); await this.assertRoleExists(client, role); const actorRole = await this.actorRole(client, workspaceId, actor.userId); if (role === OWNER && actorRole !== OWNER) throw new ForbiddenException("only an Owner can invite another Owner"); const existing = await client.query("SELECT id FROM users WHERE lower(email) = $1 ORDER BY created_at ASC LIMIT 1", [email]); let userId = existing.rows[0]?.id as string | undefined; if (userId === undefined) { const base = usernameFromEmail(email); const displayName = email.split("@")[0] ?? email; for (let attempt = 0; userId === undefined && attempt < 3; attempt += 1) { const username = attempt === 0 ? base : `${base}_${randomBytes(2).toString("hex")}`; const created = await client.query( `INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) ON CONFLICT (username) DO NOTHING RETURNING id`, [localExternalId(email), username, email, displayName], ); userId = created.rows[0]?.id as string | undefined; } if (userId === undefined) throw new ConflictException("could not derive a username for this invite"); } const inserted = await client.query( `INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'invited') ON CONFLICT (workspace_id, user_id) DO NOTHING RETURNING id`, [workspaceId, userId, role], ); const membershipId = inserted.rows[0]?.id as string | undefined; if (membershipId === undefined) throw new ConflictException(`${email} is already a member of this workspace`); return { member: await this.memberById(client, membershipId), token: await issueSessionInTenant(client, workspaceId, actor.userId), }; }); await appendTenantAudit(workspaceId, { actorUserId: actor.userId, actorRole: actor.role, action: "membership.invited", targetType: "membership", targetId: result.member.id, diff: { email, role }, }); return result; } async changeRole(workspaceId: string, actor: TeamActor, membershipId: string, role: string): Promise { const result = await withTenant(workspaceId, async (client) => { await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); await this.assertRoleExists(client, role); const target = await this.memberById(client, membershipId, true); if (target.role === role) return { member: target, token: null, previous: role }; const actorRole = await this.actorRole(client, workspaceId, actor.userId); if ((role === OWNER || target.role === OWNER) && actorRole !== OWNER) { throw new ForbiddenException("only an Owner can grant or revoke the Owner role"); } if (target.role === OWNER && target.status === "active") await this.assertNotLastOwner(client); await client.query("UPDATE memberships SET role = $2 WHERE id = $1", [membershipId, role]); return { member: await this.memberById(client, membershipId), token: await issueSessionInTenant(client, workspaceId, actor.userId), previous: target.role, }; }); if (result.previous !== role) { await appendTenantAudit(workspaceId, { actorUserId: actor.userId, actorRole: actor.role, action: "membership.role_changed", targetType: "membership", targetId: membershipId, diff: { email: result.member.email, role: [result.previous, role] }, }); } return { member: result.member, token: result.token }; } async remove(workspaceId: string, actor: TeamActor, membershipId: string): Promise { const removed = await withTenant(workspaceId, async (client) => { await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); const target = await this.memberById(client, membershipId, true); const actorRole = await this.actorRole(client, workspaceId, actor.userId); if (target.role === OWNER) { if (actorRole !== OWNER) throw new ForbiddenException("only an Owner can remove an Owner"); if (target.status === "active") await this.assertNotLastOwner(client); } await client.query("DELETE FROM memberships WHERE id = $1", [membershipId]); // Removing yourself ends your access: no replacement token. const token = target.userId === actor.userId ? null : await issueSessionInTenant(client, workspaceId, actor.userId); return { target, token }; }); await appendTenantAudit(workspaceId, { actorUserId: actor.userId, actorRole: actor.role, action: "membership.removed", targetType: "membership", targetId: membershipId, diff: { email: removed.target.email, role: removed.target.role }, }); return { member: null, token: removed.token }; } private async memberById(client: PoolClient, membershipId: string, lock = false): Promise { const res = await client.query(`${MEMBER_SELECT} WHERE m.id = $1${lock ? " FOR UPDATE OF m" : ""}`, [membershipId]); const row = res.rows[0] as MemberRow | undefined; if (row === undefined) throw new NotFoundException(`member ${membershipId} not found`); return memberFromRow(row); } private async actorRole(client: PoolClient, workspaceId: string, userId: string): Promise { const res = await client.query( "SELECT role FROM memberships WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'", [workspaceId, userId], ); const role = res.rows[0]?.role as string | undefined; if (role === undefined) throw new ForbiddenException("no active membership in this workspace"); return role; } private async assertRoleExists(client: PoolClient, role: string): Promise { const res = await client.query("SELECT 1 FROM role_permissions WHERE role = $1 LIMIT 1", [role]); if (res.rowCount === 0) throw new BadRequestException(`unknown role: ${role}`); } private async assertNotLastOwner(client: PoolClient): Promise { // Lock every Owner row so two concurrent demotions cannot both pass the check. const res = await client.query(`SELECT id FROM memberships WHERE role = $1 AND status = 'active' FOR UPDATE`, [OWNER]); if ((res.rowCount ?? 0) <= 1) throw new BadRequestException("a workspace must keep at least one active Owner"); } }