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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
214
apps/api/src/team/team.service.ts
Normal file
214
apps/api/src/team/team.service.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue