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:
1 parent
4317df802b
commit
f81d2d7906
4 files changed
+278
-1
No files matched your search
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user