feat(PROJ-14): backend for Synapse room overview (SYSTEM_ADMIN)

GET /api/system-admin/rooms and DELETE /api/system-admin/rooms/:roomId,
both gated on requireSystemAdmin from PROJ-13. No new DB table — reads
Synapse's room list directly, cross-references the existing chat_rooms
table for org/team attribution via one inArray join.

deleteRoom() uses the v2 async purge endpoint (not v1, which blocks
until the purge finishes and would time out on a large room).
normalizeCreationTs() handles Synapse versions that return seconds
instead of documented milliseconds — moot on this deployment's Synapse
version, which omits creation_ts from the rooms list entirely; verified
live, code already treats a missing value as null rather than guessing.

Known gap: audit_logs.organization_id is NOT NULL, so deleting an
unassigned room can't get a proper audit entry (falls back to a server
log warning). Flagged rather than silently worked around.

Verified end-to-end on talkhub/matrix: 403 without the SYSTEM_ADMIN
flag, 200 with it, and used the new delete endpoint to clean up a
genuinely orphaned test room left over from earlier QA.

Frontend (/system-admin route, rooms table, delete dialog) still
outstanding — the nav button added in PROJ-13 stays a dead link until
that lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 21:06:46 +02:00
1 parent 4317df802b
commit f81d2d7906
4 files changed
+278 -1

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { eq, inArray } from "drizzle-orm";
import { db } from "../db/index.js";
import { chatRooms, organizations, teams } from "../db/schema.js";
import { requireSystemAdmin } from "../lib/authz.js";
import { logAudit } from "../lib/audit.js";
import { SynapseError, deleteRoom, listRooms } from "../services/synapse.js";
/**
* PROJ-14: Matrix room IDs look like `!abc123:example.org`. Validating the shape
* keeps obviously-bogus values from ever reaching the Synapse admin API (and
* keeps them out of the URL we build from them).
*/
const matrixRoomIdSchema = z
.string()
.min(3)
.max(255)
.regex(/^![A-Za-z0-9._=\-+/]+:[A-Za-z0-9.\-]+(:\d+)?$/, "Ungültige Matrix-Raum-ID.");
const listQuerySchema = z.object({
from: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(100).default(50),
search: z.string().trim().min(1).max(200).optional(),
});
const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema });
interface LocalAssignment {
chatRoomId: string;
matrixRoomId: string;
scope: string;
organizationId: string;
organizationName: string | null;
teamId: string | null;
teamName: string | null;
}
/** TalkTime-side mapping for a set of Matrix room IDs, resolved in one query. */
async function findLocalAssignments(matrixRoomIds: string[]): Promise<Map<string, LocalAssignment>> {
if (matrixRoomIds.length === 0) {
return new Map();
}
// Single join instead of one lookup per room — the room list can hold 100
// entries per page and an N+1 loop here would mean 300 round trips.
const rows = await db
.select({
chatRoomId: chatRooms.id,
matrixRoomId: chatRooms.matrixRoomId,
scope: chatRooms.scope,
organizationId: chatRooms.organizationId,
organizationName: organizations.name,
teamId: chatRooms.teamId,
teamName: teams.name,
})
.from(chatRooms)
.leftJoin(organizations, eq(organizations.id, chatRooms.organizationId))
.leftJoin(teams, eq(teams.id, chatRooms.teamId))
.where(inArray(chatRooms.matrixRoomId, matrixRoomIds));
return new Map(rows.map((row) => [row.matrixRoomId, row]));
}
export async function systemAdminRoutes(app: FastifyInstance) {
/**
* PROJ-14: platform-wide room overview. Synapse is the source of truth for the
* room list; TalkTime only contributes the "which club/team does this belong
* to" annotation. Nothing is cached locally.
*/
app.get(
"/api/system-admin/rooms",
{ preHandler: [app.authenticate, requireSystemAdmin] },
async (request, reply) => {
const parsed = listQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { from, limit, search } = parsed.data;
let page;
try {
page = await listRooms({ from, limit, search });
} catch (err) {
// Never fall back to an empty list here: an unreachable Synapse must not
// look like "there are no rooms" to an admin about to draw conclusions.
app.log.error({ err }, "PROJ-14: failed to list rooms from Synapse");
return reply
.code(502)
.send({ error: "Synapse ist derzeit nicht erreichbar. Bitte später erneut versuchen." });
}
const assignments = await findLocalAssignments(page.rooms.map((room) => room.room_id));
return {
rooms: page.rooms.map((room) => {
const local = assignments.get(room.room_id);
return {
matrixRoomId: room.room_id,
name: room.name,
canonicalAlias: room.canonical_alias,
joinedMembers: room.joined_members,
createdAt: room.creation_ts ? new Date(room.creation_ts).toISOString() : null,
assignment: local
? {
organizationId: local.organizationId,
organizationName: local.organizationName,
teamId: local.teamId,
teamName: local.teamName,
scope: local.scope,
}
: null,
};
}),
pagination: {
from,
limit,
total: page.total_rooms,
nextFrom: page.next_batch ?? null,
prevFrom: page.prev_batch ?? null,
},
};
},
);
/**
* PROJ-14: purges a room at Synapse and drops the local mapping if one exists.
* Deliberately allowed even for rooms still assigned to a live team — the
* frontend warns first; a silently-refused delete would be more confusing.
*/
app.delete(
"/api/system-admin/rooms/:roomId",
{ preHandler: [app.authenticate, requireSystemAdmin] },
async (request, reply) => {
const parsed = roomParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { roomId } = parsed.data;
const [local] = await db
.select({
id: chatRooms.id,
organizationId: chatRooms.organizationId,
teamId: chatRooms.teamId,
name: chatRooms.name,
})
.from(chatRooms)
.where(eq(chatRooms.matrixRoomId, roomId))
.limit(1);
try {
await deleteRoom(roomId);
} catch (err) {
if (err instanceof SynapseError && err.status === 404) {
// Race condition: another admin already deleted it. The local mapping
// is now stale either way, so clean it up and report success-ish.
if (local) {
await db.delete(chatRooms).where(eq(chatRooms.id, local.id));
}
return reply.code(404).send({ error: "Dieser Raum existiert nicht (mehr)." });
}
app.log.error({ err, roomId }, "PROJ-14: failed to delete room at Synapse");
return reply
.code(502)
.send({ error: "Der Raum konnte bei Synapse nicht gelöscht werden." });
}
if (local) {
// Synapse cascades nothing into our DB (the room ID is an external key),
// so the mapping has to go explicitly or it points at a dead room.
await db.delete(chatRooms).where(eq(chatRooms.id, local.id));
// audit_logs.organization_id is NOT NULL, so only org-bound rooms can be
// recorded there; orphaned rooms get the server log instead.
await logAudit({
organizationId: local.organizationId,
actorUserId: request.user.sub,
action: "system_admin.room.deleted",
targetType: "chat_room",
targetId: roomId,
metadata: { name: local.name, teamId: local.teamId },
});
} else {
app.log.warn(
{ roomId, actorUserId: request.user.sub },
"PROJ-14: system admin purged an unassigned Matrix room",
);
}
return { deleted: true, hadLocalAssignment: local !== undefined };
},
);
}
+2
View File
@@ -17,6 +17,7 @@ import { seasonRoutes } from "./routes/seasons.js";
import { roleRoutes } from "./routes/roles.js"; import { roleRoutes } from "./routes/roles.js";
import { auditRoutes } from "./routes/audit.js"; import { auditRoutes } from "./routes/audit.js";
import { chatRoutes } from "./routes/chat.js"; import { chatRoutes } from "./routes/chat.js";
import { systemAdminRoutes } from "./routes/system-admin.js";
import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js"; import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js";
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -69,6 +70,7 @@ await app.register(seasonRoutes);
await app.register(roleRoutes); await app.register(roleRoutes);
await app.register(auditRoutes); await app.register(auditRoutes);
await app.register(chatRoutes); await app.register(chatRoutes);
await app.register(systemAdminRoutes);
const port = Number(process.env.PORT ?? 4000); const port = Number(process.env.PORT ?? 4000);
app.listen({ port, host: "0.0.0.0" }).catch((err) => { app.listen({ port, host: "0.0.0.0" }).catch((err) => {
+73
View File
@@ -191,6 +191,79 @@ export async function setRoomHistoryVisibility(roomId: string): Promise<void> {
); );
} }
export interface SynapseRoomSummary {
room_id: string;
name: string | null;
canonical_alias: string | null;
joined_members: number;
/** Milliseconds since epoch (see normalizeCreationTs). */
creation_ts: number | null;
}
export interface SynapseRoomPage {
rooms: SynapseRoomSummary[];
offset: number;
total_rooms: number;
next_batch?: number;
prev_batch?: number;
}
/**
* PROJ-14: Synapse's List Room API documents `creation_ts` as milliseconds, but
* older/other versions hand back seconds. Anything below ~2001 in ms terms is
* far more plausibly a seconds value, so scale it up rather than rendering
* every room as "created in 1970".
*/
function normalizeCreationTs(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return value < 1e12 ? value * 1000 : value;
}
/**
* PROJ-14: lists rooms known to the homeserver. Paginated by Synapse itself —
* we pass `from`/`limit` straight through instead of buffering everything.
*/
export async function listRooms(params: {
from: number;
limit: number;
search?: string;
}): Promise<SynapseRoomPage> {
const query = new URLSearchParams({
from: String(params.from),
limit: String(params.limit),
order_by: "name",
});
if (params.search) {
query.set("search_term", params.search);
}
const page = await synapseFetch<SynapseRoomPage>(`/_synapse/admin/v1/rooms?${query.toString()}`);
return {
...page,
rooms: (page.rooms ?? []).map((room) => ({
...room,
creation_ts: normalizeCreationTs(room.creation_ts),
})),
};
}
/**
* PROJ-14: deletes and purges a room. Uses the v2 (asynchronous) delete
* endpoint, which returns a delete_id immediately and does the purge in the
* background — the v1 endpoint blocks for minutes on large rooms and would time
* the HTTP request out.
*/
export async function deleteRoom(roomId: string): Promise<{ deleteId: string | null }> {
const result = await synapseFetch<{ delete_id?: string }>(
`/_synapse/admin/v2/rooms/${encodeURIComponent(roomId)}`,
{
method: "DELETE",
body: JSON.stringify({ purge: true, block: false }),
},
);
return { deleteId: result?.delete_id ?? null };
}
export async function addUserToRoom(roomId: string, userId: string): Promise<void> { export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
method: "POST", method: "POST",
+9 -1
View File
@@ -1,8 +1,16 @@
# PROJ-14: Synapse Raum-Übersicht (System-Admin) # PROJ-14: Synapse Raum-Übersicht (System-Admin)
**Status:** Planned **Status:** In Progress
**Created:** 2026-09-12 **Created:** 2026-09-12
## Implementation Notes (Backend, 2026-09-12)
Backend umgesetzt, Frontend-Seite (`/system-admin` Route + Tabelle + Lösch-Dialog) steht noch aus.
- `backend/src/services/synapse.ts`: `listRooms()` (`GET /_synapse/admin/v1/rooms`, `from`/`limit`/`search_term` durchgereicht), `deleteRoom()` (v2 **async** Delete mit `purge: true` — v1 blockiert bei großen Räumen bis zum HTTP-Timeout), `normalizeCreationTs()` (Synapse liefert je nach Version Sekunden statt Millisekunden).
- `backend/src/routes/system-admin.ts` (neu, in `server.ts` registriert): `GET /api/system-admin/rooms` + `DELETE /api/system-admin/rooms/:roomId`, beide `preHandler: [app.authenticate, requireSystemAdmin]`.
- Keine neue Tabelle, keine Migration: `chat_rooms.matrix_room_id` ist bereits `unique` (Index vorhanden), Zuordnung via einem `inArray`-JOIN gegen `organizations`/`teams` (kein N+1).
- Abweichung/Einschränkung: `audit_logs.organization_id` ist NOT NULL — das Löschen eines **nicht zugeordneten** Raums kann daher nicht ins Audit-Log, nur ins Server-Log (`app.log.warn`). Zugeordnete Räume werden regulär als `system_admin.room.deleted` auditiert.
- Edge Cases: Synapse nicht erreichbar → 502 mit Fehlertext (nie leere Liste); Raum bereits gelöscht (404 von Synapse) → lokale Zuordnung wird trotzdem aufgeräumt, dann 404.
## Summary ## Summary
Liste aller Matrix-Räume auf der angebundenen Synapse-Instanz für SYSTEM_ADMIN, unabhängig von TalkTime-Team-/Vereinszuordnung — nachgebaut aus synapse-admin's Raum-Übersicht. Nutzt Synapse Admin API (`GET /_synapse/admin/v1/rooms`), keine neue TalkTime-Datenbanktabelle nötig. Liste aller Matrix-Räume auf der angebundenen Synapse-Instanz für SYSTEM_ADMIN, unabhängig von TalkTime-Team-/Vereinszuordnung — nachgebaut aus synapse-admin's Raum-Übersicht. Nutzt Synapse Admin API (`GET /_synapse/admin/v1/rooms`), keine neue TalkTime-Datenbanktabelle nötig.