401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
|
import {
|
|
DEFAULT_MANUAL_LANGUAGE,
|
|
MANUAL_LANGUAGES,
|
|
isManualLanguage,
|
|
type ManualLanguage,
|
|
} from "@tactical-media-broadcast-hub/core";
|
|
import type { PoolClient } from "pg";
|
|
import { appendTenantAudit } from "../audit/audit.service.js";
|
|
import { withSystem, withTenant } from "../db/client.js";
|
|
|
|
/**
|
|
* Collectives — P1 of docs/design/2026-10-12-collectives-architecture.md.
|
|
*
|
|
* A collective IS a group: `groups.parent_id` self-references (root groups are
|
|
* collectives, children are subcollectives) and `groups.kind` discriminates them
|
|
* from the 008-era sharing-scope groups ('plain'). The room set is mapped in
|
|
* `group_rooms` (one open, one closed room per collective plus the founder's
|
|
* onboarding room), provisioned through PROVISIONER_PORT so the real Matrix
|
|
* implementation is swap-faked in tests.
|
|
*
|
|
* Provisioning order: rooms are created on the homeserver FIRST, then the group,
|
|
* the founder's group_members row and the group_rooms mappings land in ONE
|
|
* tenant transaction. A remote side effect cannot be rolled back, so the DB is
|
|
* only written once the full room set exists — a failed homeserver leaves no
|
|
* collective at all (orphan rooms are invisible; a collective with missing
|
|
* rooms would be a lie).
|
|
*/
|
|
|
|
export const COLLECTIVE_ROOM_TYPES = ["open", "closed", "onboarding"] as const;
|
|
export type CollectiveRoomType = (typeof COLLECTIVE_ROOM_TYPES)[number];
|
|
|
|
export type GroupKind = "collective" | "subcollective" | "plain";
|
|
|
|
/** Power levels the provisioner pins at creation (design §Provisioning). */
|
|
export const FOUNDER_POWER_LEVEL = 100;
|
|
export const MEMBER_POWER_LEVEL = 0;
|
|
|
|
export interface CollectiveActor {
|
|
userId: string;
|
|
role: string;
|
|
}
|
|
|
|
export interface ProvisionedRoom {
|
|
roomId: string;
|
|
}
|
|
|
|
/** A whole provisioned room set, exactly as the provisioner produced it. */
|
|
export interface ProvisionedRoomSet {
|
|
open: ProvisionedRoom;
|
|
closed: ProvisionedRoom;
|
|
onboarding: ProvisionedRoom;
|
|
}
|
|
|
|
export interface ProvisionCollectiveInput {
|
|
workspaceId: string;
|
|
/** Informational only at provision time; never used as a foreign key. */
|
|
groupId: string;
|
|
name: string;
|
|
language: ManualLanguage;
|
|
founderUserId: string;
|
|
/** Founder's Matrix user when this deployment knows it; null = skip invites. */
|
|
founderMxid: string | null;
|
|
}
|
|
|
|
/**
|
|
* The seam between the Hub and the homeserver. MatrixProvisioner is the only
|
|
* implementation that knows Matrix exists; the service, the controller and
|
|
* every test work against this interface (see InMemoryProvisioner in tests).
|
|
*
|
|
* Throws ProvisionerUnavailableError when the homeserver cannot be reached or
|
|
* refuses — the service maps that to 503 without persisting anything.
|
|
*/
|
|
export interface ProvisionerPort {
|
|
readonly botUserId: string;
|
|
provisionCollective(input: ProvisionCollectiveInput): Promise<ProvisionedRoomSet>;
|
|
}
|
|
|
|
export class ProvisionerUnavailableError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ProvisionerUnavailableError";
|
|
}
|
|
}
|
|
|
|
/** DI token; the module binds MatrixProvisioner (or a fake) to it. */
|
|
export const PROVISIONER_PORT = Symbol("PROVISIONER_PORT");
|
|
|
|
export interface CollectiveRoomSummary {
|
|
roomType: CollectiveRoomType;
|
|
roomId: string;
|
|
}
|
|
|
|
export interface CollectiveNode {
|
|
id: string;
|
|
name: string;
|
|
kind: GroupKind;
|
|
language: ManualLanguage;
|
|
parentId: string | null;
|
|
description: string;
|
|
createdAt: string;
|
|
memberCount: number;
|
|
rooms: CollectiveRoomSummary[];
|
|
children: CollectiveNode[];
|
|
}
|
|
|
|
export interface CreateCollectiveInput {
|
|
name: string;
|
|
language: ManualLanguage;
|
|
description?: string;
|
|
}
|
|
|
|
const UNIQUE_VIOLATION = "23505";
|
|
const FK_VIOLATION = "23503";
|
|
|
|
export 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;
|
|
}
|
|
|
|
/** Language parsing shared by the controller and the service. */
|
|
export function parseLanguage(value: unknown): ManualLanguage {
|
|
if (value === undefined || value === null || value === "") return DEFAULT_MANUAL_LANGUAGE;
|
|
if (!isManualLanguage(value)) {
|
|
throw new BadRequestException(`language must be one of ${MANUAL_LANGUAGES.join(" | ")}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** A name a collective can carry; same shape rule as GroupsService. */
|
|
export function collectiveName(name: string): string {
|
|
const clean = name.trim();
|
|
if (clean === "") throw new BadRequestException("name must be a non-empty string");
|
|
if (clean.length > 120) throw new BadRequestException("name must be at most 120 characters");
|
|
return clean;
|
|
}
|
|
|
|
/**
|
|
* The collective's language rides the existing `description` column as a
|
|
* trailing `lang:<es|en>` line rather than a new column: P1 keeps the migration
|
|
* to exactly the three objects the design names (parent_id, kind, group_rooms),
|
|
* and the language is presentation metadata, not relational data.
|
|
*/
|
|
const LANGUAGE_TAG_PREFIX = "lang:";
|
|
|
|
function stripLanguageTag(description: string): string {
|
|
const lines = description.split("\n");
|
|
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
|
|
if (lines.length > 0 && lines[lines.length - 1]!.startsWith(LANGUAGE_TAG_PREFIX)) lines.pop();
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function encodeLanguage(description: string, language: ManualLanguage): string {
|
|
const stripped = stripLanguageTag(description);
|
|
return stripped === "" ? `${LANGUAGE_TAG_PREFIX}${language}` : `${stripped}\n${LANGUAGE_TAG_PREFIX}${language}`;
|
|
}
|
|
|
|
export function decodeLanguage(description: string | null, fallback: ManualLanguage = DEFAULT_MANUAL_LANGUAGE): ManualLanguage {
|
|
if (typeof description !== "string") return fallback;
|
|
const lines = description.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
const line = lines[i]!.trim();
|
|
if (line === "") continue;
|
|
if (line.startsWith(LANGUAGE_TAG_PREFIX)) {
|
|
const value = line.slice(LANGUAGE_TAG_PREFIX.length);
|
|
return isManualLanguage(value) ? value : fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/** The description as any client should render it: stored text minus the language tag. */
|
|
export function publicDescription(description: string | null): string {
|
|
return stripLanguageTag(description ?? "");
|
|
}
|
|
|
|
interface GroupRow {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
kind: GroupKind;
|
|
parent_id: string | null;
|
|
created_at: Date;
|
|
member_count: string | number;
|
|
}
|
|
|
|
interface RoomRow {
|
|
group_id: string;
|
|
room_type: CollectiveRoomType;
|
|
room_id: string;
|
|
}
|
|
|
|
function findNode(nodes: CollectiveNode[], id: string): CollectiveNode | undefined {
|
|
for (const node of nodes) {
|
|
if (node.id === id) return node;
|
|
const found = findNode(node.children, id);
|
|
if (found !== undefined) return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CollectivesService {
|
|
constructor(@Inject(PROVISIONER_PORT) private readonly provisioner: ProvisionerPort) {}
|
|
|
|
async createCollective(workspaceId: string, input: CreateCollectiveInput, actor: CollectiveActor): Promise<CollectiveNode> {
|
|
const name = collectiveName(input.name);
|
|
const language = input.language;
|
|
|
|
let rooms: ProvisionedRoomSet;
|
|
try {
|
|
rooms = await this.provisioner.provisionCollective({
|
|
workspaceId,
|
|
groupId: "pending",
|
|
name,
|
|
language,
|
|
founderUserId: actor.userId,
|
|
founderMxid: null,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof ProvisionerUnavailableError) {
|
|
throw new InternalServerErrorException(`collective rooms could not be created: ${err.message}`);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const groupId = await withTenant(workspaceId, async (client) => {
|
|
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
|
let id: string;
|
|
try {
|
|
const res = await client.query(
|
|
`INSERT INTO groups (workspace_id, name, description, kind, parent_id)
|
|
VALUES ($1, $2, $3, 'collective', NULL) RETURNING id`,
|
|
[workspaceId, name, encodeLanguage(input.description ?? "", language)],
|
|
);
|
|
id = res.rows[0]!.id as string;
|
|
} catch (err) {
|
|
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${name}" already exists`);
|
|
throw err;
|
|
}
|
|
await this.insertFounder(client, workspaceId, id, actor.userId);
|
|
await this.insertRooms(client, workspaceId, id, [
|
|
["open", rooms.open.roomId],
|
|
["closed", rooms.closed.roomId],
|
|
["onboarding", rooms.onboarding.roomId],
|
|
]);
|
|
return id;
|
|
});
|
|
|
|
await appendTenantAudit(workspaceId, {
|
|
actorUserId: actor.userId,
|
|
actorRole: actor.role,
|
|
action: "collective.created",
|
|
targetType: "group",
|
|
targetId: groupId,
|
|
// Room IDs only — never tokens, never power-level payloads.
|
|
diff: { name, language, kind: "collective", openRoom: rooms.open.roomId, closedRoom: rooms.closed.roomId },
|
|
});
|
|
|
|
const tree = await this.collectiveTree(workspaceId);
|
|
const node = findNode(tree, groupId);
|
|
if (node === undefined) throw new InternalServerErrorException("collective was created but is not readable in its workspace");
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Root-collective count + the workspace's tenancy kind, for the post-login
|
|
* home decision (operator flow reorder #3). A personal workspace reports zero
|
|
* by construction — it never contains collective rows — but the kind is
|
|
* returned anyway so the client can label the menu honestly ("solo account")
|
|
* without a second round-trip.
|
|
*/
|
|
async myCollectiveCount(workspaceId: string): Promise<{ collectives: number; workspaceKind: "shared" | "personal" }> {
|
|
return withTenant(workspaceId, async (client) => {
|
|
const rows = await client.query<{ n: string }>(
|
|
"SELECT count(*)::text AS n FROM groups WHERE workspace_id = $1 AND kind = 'collective'",
|
|
[workspaceId],
|
|
);
|
|
const kind = await client.query<{ kind: "shared" | "personal" }>("SELECT kind FROM workspaces WHERE id = $1", [workspaceId]);
|
|
return { collectives: Number(rows.rows[0]?.n ?? 0), workspaceKind: kind.rows[0]?.kind ?? "shared" };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The collective tree for the workspace: collectives as roots, subcollectives
|
|
* nested, every node carrying its room set. 'plain' groups are sharing
|
|
* scopes, not participation units, so they are not part of this tree.
|
|
*/
|
|
async collectiveTree(workspaceId: string): Promise<CollectiveNode[]> {
|
|
const res = await withTenant(workspaceId, async (client) => {
|
|
const groups = await client.query(
|
|
`SELECT g.id, g.name, g.description, g.kind, g.parent_id, g.created_at,
|
|
(SELECT count(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
|
|
FROM groups g
|
|
WHERE g.kind IN ('collective','subcollective')
|
|
ORDER BY g.created_at ASC, g.id ASC`,
|
|
);
|
|
const rooms = await client.query(
|
|
"SELECT group_id, room_type, room_id FROM group_rooms WHERE workspace_id = $1",
|
|
[workspaceId],
|
|
);
|
|
return { groups, rooms: rooms.rows as RoomRow[] };
|
|
});
|
|
|
|
const byId = new Map<string, CollectiveNode>();
|
|
for (const row of res.groups.rows as GroupRow[]) {
|
|
byId.set(row.id, {
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind,
|
|
language: decodeLanguage(row.description),
|
|
parentId: row.parent_id,
|
|
description: publicDescription(row.description),
|
|
createdAt: new Date(row.created_at).toISOString(),
|
|
memberCount: Number(row.member_count),
|
|
rooms: [],
|
|
children: [],
|
|
});
|
|
}
|
|
const roomIndex = new Map<string, CollectiveRoomSummary[]>();
|
|
for (const row of res.rooms) {
|
|
const list = roomIndex.get(row.group_id) ?? [];
|
|
list.push({ roomType: row.room_type, roomId: row.room_id });
|
|
roomIndex.set(row.group_id, list);
|
|
}
|
|
const roots: CollectiveNode[] = [];
|
|
for (const node of byId.values()) {
|
|
node.rooms = (roomIndex.get(node.id) ?? []).sort((a, b) => a.roomType.localeCompare(b.roomType));
|
|
if (node.parentId !== null && byId.has(node.parentId)) byId.get(node.parentId)!.children.push(node);
|
|
else roots.push(node);
|
|
}
|
|
return roots.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
}
|
|
|
|
/**
|
|
* Join-by-invite integration (P1): given an invite token, the collective
|
|
* NAMES of the workspace that token points at — nothing else.
|
|
*
|
|
* The joiner has NO tenant context (they have not claimed yet), so the
|
|
* workspace is resolved through the existing narrow SECURITY DEFINER lookup
|
|
* `invite_link_for_claim(token)` (017): the caller proves possession of the
|
|
* secret first, and the projection stays at names-only (no room ids, no
|
|
* member counts, no descriptions). The claim itself is untouched —
|
|
* InviteClaimController keeps doing exactly what it did.
|
|
*/
|
|
async collectivesForInviteToken(token: string): Promise<{ collectives: Array<{ id: string; name: string }> }> {
|
|
// Step 1 resolves the workspace from the secret the caller proved possession
|
|
// of (SECURITY DEFINER, no tenant context needed). Step 2 reads the names
|
|
// inside a tenant transaction, because `groups` is FORCE RLS: without
|
|
// app.workspace_id even this service would see zero rows — by design.
|
|
const workspaceId = await withSystem(async (client) => {
|
|
const link = await client.query("SELECT workspace_id, revoked_at, expires_at FROM invite_link_for_claim($1)", [token]);
|
|
const row = link.rows[0] as { workspace_id: string; revoked_at: Date | null; expires_at: Date } | undefined;
|
|
if (row === undefined) throw new NotFoundException("invite link is invalid or has expired");
|
|
if (row.revoked_at !== null) throw new GoneException("invite link has been revoked");
|
|
if (new Date(row.expires_at).getTime() <= Date.now()) throw new GoneException("invite link has expired");
|
|
return row.workspace_id;
|
|
});
|
|
return withTenant(workspaceId, async (client) => {
|
|
const names = await client.query(
|
|
"SELECT id, name FROM groups WHERE workspace_id = $1 AND kind IN ('collective','subcollective') ORDER BY created_at ASC",
|
|
[workspaceId],
|
|
);
|
|
return {
|
|
collectives: (names.rows as Array<{ id: string; name: string }>).map((row) => ({ id: row.id, name: row.name })),
|
|
};
|
|
});
|
|
}
|
|
|
|
private async insertFounder(client: PoolClient, workspaceId: string, groupId: string, userId: string): Promise<void> {
|
|
try {
|
|
await client.query(
|
|
`INSERT INTO group_members (workspace_id, group_id, user_id)
|
|
VALUES ($1, $2, $3) ON CONFLICT (group_id, user_id) DO NOTHING`,
|
|
[workspaceId, groupId, userId],
|
|
);
|
|
} catch (err) {
|
|
// Composite FK to memberships: the founder is not a member of this workspace.
|
|
if (pgCode(err) === FK_VIOLATION) throw new BadRequestException("founder is not a member of this workspace");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async insertRooms(
|
|
client: PoolClient,
|
|
workspaceId: string,
|
|
groupId: string,
|
|
rooms: ReadonlyArray<readonly [CollectiveRoomType, string]>,
|
|
): Promise<void> {
|
|
for (const [roomType, roomId] of rooms) {
|
|
await client.query("INSERT INTO group_rooms (workspace_id, group_id, room_type, room_id) VALUES ($1, $2, $3, $4)", [
|
|
workspaceId,
|
|
groupId,
|
|
roomType,
|
|
roomId,
|
|
]);
|
|
}
|
|
}
|
|
}
|