T-HUB/apps/api/src/team/team.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

214 lines
9.2 KiB
TypeScript

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<Member[]> {
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<string[]> {
// 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<MemberMutationResult> {
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<MemberMutationResult> {
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<MemberMutationResult> {
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<Member> {
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<string> {
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<void> {
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<void> {
// 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");
}
}