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/.
472 lines
18 KiB
TypeScript
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;
|
|
}
|