From 6e14f48770b9cb80ebcad6509c08e769182fb703 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 18 May 2026 18:19:23 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Matrix-R=C3=A4ume=20persistent=20ve?= =?UTF-8?q?rwalten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0038_communication_rooms.sql | 43 ++++ backend/db/schema/communication_rooms.ts | 57 +++++ backend/db/schema/index.ts | 1 + backend/src/modules/matrix.service.ts | 200 ++++++++++++++++-- backend/src/routes/communication.ts | 16 +- 5 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 backend/db/migrations/0038_communication_rooms.sql create mode 100644 backend/db/schema/communication_rooms.ts diff --git a/backend/db/migrations/0038_communication_rooms.sql b/backend/db/migrations/0038_communication_rooms.sql new file mode 100644 index 0000000..2206110 --- /dev/null +++ b/backend/db/migrations/0038_communication_rooms.sql @@ -0,0 +1,43 @@ +CREATE TABLE "communication_rooms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "topic" text, + "type" text DEFAULT 'room' NOT NULL, + "entity_type" text, + "entity_id" bigint, + "entity_uuid" uuid, + "matrix_room_id" text, + "matrix_alias" text, + "parent_space_room_id" text, + "archived" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "created_by" uuid, + "updated_by" uuid +); + +ALTER TABLE "communication_rooms" + ADD CONSTRAINT "communication_rooms_tenant_id_tenants_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") + ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "communication_rooms" + ADD CONSTRAINT "communication_rooms_created_by_auth_users_id_fk" + FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") + ON DELETE no action ON UPDATE no action; + +ALTER TABLE "communication_rooms" + ADD CONSTRAINT "communication_rooms_updated_by_auth_users_id_fk" + FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") + ON DELETE no action ON UPDATE no action; + +CREATE UNIQUE INDEX "communication_rooms_tenant_key_idx" + ON "communication_rooms" USING btree ("tenant_id", "key"); + +CREATE INDEX "communication_rooms_tenant_idx" + ON "communication_rooms" USING btree ("tenant_id"); + +CREATE INDEX "communication_rooms_entity_idx" + ON "communication_rooms" USING btree ("tenant_id", "entity_type", "entity_id", "entity_uuid"); diff --git a/backend/db/schema/communication_rooms.ts b/backend/db/schema/communication_rooms.ts new file mode 100644 index 0000000..0302b25 --- /dev/null +++ b/backend/db/schema/communication_rooms.ts @@ -0,0 +1,57 @@ +import { + pgTable, + uuid, + bigint, + text, + timestamp, + boolean, + uniqueIndex, + index, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const communicationRooms = pgTable( + "communication_rooms", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + + key: text("key").notNull(), + name: text("name").notNull(), + topic: text("topic"), + type: text("type").notNull().default("room"), + + entityType: text("entity_type"), + entityId: bigint("entity_id", { mode: "number" }), + entityUuid: uuid("entity_uuid"), + + matrixRoomId: text("matrix_room_id"), + matrixAlias: text("matrix_alias"), + parentSpaceRoomId: text("parent_space_room_id"), + + archived: boolean("archived").notNull().default(false), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), + createdBy: uuid("created_by").references(() => authUsers.id), + updatedBy: uuid("updated_by").references(() => authUsers.id), + }, + (table) => ({ + tenantKeyIdx: uniqueIndex("communication_rooms_tenant_key_idx") + .on(table.tenantId, table.key), + tenantIdx: index("communication_rooms_tenant_idx") + .on(table.tenantId), + entityIdx: index("communication_rooms_entity_idx") + .on(table.tenantId, table.entityType, table.entityId, table.entityUuid), + }) +) + +export type CommunicationRoom = typeof communicationRooms.$inferSelect +export type NewCommunicationRoom = typeof communicationRooms.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 9dabfcc..67deac1 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -14,6 +14,7 @@ export * from "./branches" export * from "./checkexecutions" export * from "./checks" export * from "./citys" +export * from "./communication_rooms" export * from "./contacts" export * from "./contracts" export * from "./contracttypes" diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 9d28245..b9ac2b2 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { resolve } from "node:path" import { FastifyInstance } from "fastify" -import { authProfiles, authUsers, tenants } from "../../db/schema" +import { authProfiles, authUsers, communicationRooms, tenants } from "../../db/schema" import { and, eq } from "drizzle-orm" import { secrets } from "../utils/secrets" @@ -39,6 +39,10 @@ type MatrixTenantRoomOptions = { key?: string name?: string topic?: string + type?: string + entityType?: string | null + entityId?: number | null + entityUuid?: string | null } type MatrixCachedValue = { @@ -54,10 +58,11 @@ const matrixTenantSpaceCache = new Map() const matrixTenantRoomCache = new Map() let matrixServiceSessionCache: MatrixUserSession | null = null -const defaultTenantRooms: Required>[] = [ +const defaultTenantRooms: Required>[] = [ { key: "allgemein", name: "Allgemeiner Chat", + type: "general", }, ] @@ -196,6 +201,10 @@ export function matrixService(server: FastifyInstance) { key, name, topic, + type: options.type || "room", + entityType: options.entityType || null, + entityId: options.entityId || null, + entityUuid: options.entityUuid || null, } } @@ -618,28 +627,154 @@ export function matrixService(server: FastifyInstance) { return value } + const roomMetadataToApi = ( + tenant: { id: number, name?: string | null, short?: string | null }, + room: typeof communicationRooms.$inferSelect + ) => ({ + id: room.id, + tenantId: tenant.id, + tenantName: tenant.name, + key: room.key, + name: room.name, + topic: room.topic, + type: room.type, + entityType: room.entityType, + entityId: room.entityId, + entityUuid: room.entityUuid, + alias: room.matrixAlias || tenantRoomAlias(tenant, room.key), + exists: Boolean(room.matrixRoomId), + roomId: room.matrixRoomId, + parentSpaceRoomId: room.parentSpaceRoomId, + servers: [], + archived: room.archived, + }) + + const findTenantRoomMetadata = async (tenantId: number, key: string) => { + const [room] = await server.db + .select() + .from(communicationRooms) + .where(and( + eq(communicationRooms.tenantId, tenantId), + eq(communicationRooms.key, key) + )) + .limit(1) + + return room + } + + const ensureTenantRoomMetadata = async ( + tenant: { id: number, name?: string | null, short?: string | null }, + options: MatrixTenantRoomOptions + ) => { + const normalizedOptions = normalizeTenantRoomOptions(options) + const existing = await findTenantRoomMetadata(tenant.id, normalizedOptions.key) + + if (existing) { + const shouldUpdate = + (options.name !== undefined && existing.name !== normalizedOptions.name) || + (options.topic !== undefined && existing.topic !== normalizedOptions.topic) || + (options.type !== undefined && existing.type !== normalizedOptions.type) || + (options.entityType !== undefined && existing.entityType !== normalizedOptions.entityType) || + (options.entityId !== undefined && existing.entityId !== normalizedOptions.entityId) || + (options.entityUuid !== undefined && existing.entityUuid !== normalizedOptions.entityUuid) + + if (!shouldUpdate) return existing + + const [updated] = await server.db + .update(communicationRooms) + .set({ + name: options.name !== undefined ? normalizedOptions.name : existing.name, + topic: options.topic !== undefined ? normalizedOptions.topic : existing.topic, + type: options.type !== undefined ? normalizedOptions.type : existing.type, + entityType: options.entityType !== undefined ? normalizedOptions.entityType : existing.entityType, + entityId: options.entityId !== undefined ? normalizedOptions.entityId : existing.entityId, + entityUuid: options.entityUuid !== undefined ? normalizedOptions.entityUuid : existing.entityUuid, + updatedAt: new Date(), + }) + .where(eq(communicationRooms.id, existing.id)) + .returning() + + return updated + } + + const [created] = await server.db + .insert(communicationRooms) + .values({ + tenantId: tenant.id, + key: normalizedOptions.key, + name: normalizedOptions.name, + topic: normalizedOptions.topic, + type: normalizedOptions.type, + entityType: normalizedOptions.entityType, + entityId: normalizedOptions.entityId, + entityUuid: normalizedOptions.entityUuid, + matrixAlias: tenantRoomAlias(tenant, normalizedOptions.key), + }) + .returning() + + return created + } + + const markTenantRoomProvisioned = async ( + metadataId: string, + values: { + matrixRoomId: string + matrixAlias: string + parentSpaceRoomId?: string | null + } + ) => { + const [updated] = await server.db + .update(communicationRooms) + .set({ + matrixRoomId: values.matrixRoomId, + matrixAlias: values.matrixAlias, + parentSpaceRoomId: values.parentSpaceRoomId || null, + updatedAt: new Date(), + }) + .where(eq(communicationRooms.id, metadataId)) + .returning() + + return updated + } + const getTenantRoomStatus = async ( tenantId: number | null, roomKey: string, - roomName: string + roomName?: string ) => { const tenant = await getCurrentTenant(tenantId) - const alias = tenantRoomAlias(tenant, roomKey) + const normalizedOptions = normalizeTenantRoomOptions({ key: roomKey, name: roomName }) + const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) + const alias = metadata.matrixAlias || tenantRoomAlias(tenant, normalizedOptions.key) try { const directoryEntry = await requestJson<{ room_id: string servers: string[] }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) + const roomMetadata = metadata.matrixRoomId === directoryEntry.room_id + ? metadata + : await markTenantRoomProvisioned(metadata.id, { + matrixRoomId: directoryEntry.room_id, + matrixAlias: alias, + parentSpaceRoomId: metadata.parentSpaceRoomId, + }) return { tenantId: tenant.id, tenantName: tenant.name, - key: roomKey, - name: roomName, + id: roomMetadata.id, + key: roomMetadata.key, + name: roomMetadata.name, + topic: roomMetadata.topic, + type: roomMetadata.type, + entityType: roomMetadata.entityType, + entityId: roomMetadata.entityId, + entityUuid: roomMetadata.entityUuid, alias, exists: true, roomId: directoryEntry.room_id, + parentSpaceRoomId: roomMetadata.parentSpaceRoomId, servers: directoryEntry.servers, } } catch (err: any) { @@ -647,11 +782,18 @@ export function matrixService(server: FastifyInstance) { return { tenantId: tenant.id, tenantName: tenant.name, - key: roomKey, - name: roomName, + id: metadata.id, + key: metadata.key, + name: metadata.name, + topic: metadata.topic, + type: metadata.type, + entityType: metadata.entityType, + entityId: metadata.entityId, + entityUuid: metadata.entityUuid, alias, exists: false, - roomId: null, + roomId: metadata.matrixRoomId, + parentSpaceRoomId: metadata.parentSpaceRoomId, servers: [], } } @@ -667,6 +809,7 @@ export function matrixService(server: FastifyInstance) { ) => { const tenant = await getCurrentTenant(tenantId) const normalizedOptions = normalizeTenantRoomOptions(options) + const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions) const key = normalizedOptions.key const name = normalizedOptions.name const topic = (normalizedOptions.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() @@ -682,6 +825,12 @@ export function matrixService(server: FastifyInstance) { const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id) if (existing.exists) { + await markTenantRoomProvisioned(metadata.id, { + matrixRoomId: existing.roomId, + matrixAlias: existing.alias, + parentSpaceRoomId: existing.parentSpaceRoomId, + }) + const value = { ...existing, created: false, @@ -748,10 +897,16 @@ export function matrixService(server: FastifyInstance) { ) const value = { + id: metadata.id, tenantId: tenant.id, tenantName: tenant.name, key, name, + topic, + type: metadata.type, + entityType: metadata.entityType, + entityId: metadata.entityId, + entityUuid: metadata.entityUuid, alias: tenantRoomAlias(tenant, key), exists: true, created: true, @@ -762,6 +917,12 @@ export function matrixService(server: FastifyInstance) { serviceUserId: serviceLogin.matrixUserId, } + await markTenantRoomProvisioned(metadata.id, { + matrixRoomId: value.roomId, + matrixAlias: value.alias, + parentSpaceRoomId: value.parentSpaceRoomId, + }) + matrixTenantRoomCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, @@ -805,26 +966,23 @@ export function matrixService(server: FastifyInstance) { const listTenantRooms = async (tenantId: number | null) => { const tenant = await getCurrentTenant(tenantId) - const rooms = new Map() for (const room of defaultTenantRooms) { - rooms.set(room.key, await getTenantRoomStatus(tenant.id, room.key, room.name)) + await ensureTenantRoomMetadata(tenant, room) } - for (const [cacheKey, cachedRoom] of matrixTenantRoomCache.entries()) { - const [cachedTenantId, roomKey] = cacheKey.split(":") - if (cachedTenantId !== String(tenant.id) || !cachedRoom.value) continue - - rooms.set(roomKey, { - ...cachedRoom.value, - exists: true, - }) - } + const rooms = await server.db + .select() + .from(communicationRooms) + .where(and( + eq(communicationRooms.tenantId, tenant.id), + eq(communicationRooms.archived, false) + )) return { tenantId: tenant.id, tenantName: tenant.name, - rooms: Array.from(rooms.values()).sort((a, b) => + rooms: rooms.map((room) => roomMetadataToApi(tenant, room)).sort((a, b) => String(a.name || a.key).localeCompare(String(b.name || b.key), "de") ), } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 322b2b6..a47afcd 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -12,12 +12,24 @@ export default async function communicationRoutes(server: FastifyInstance) { const roomOptionsFromRequest = (req: any) => { const params = req.params as { roomKey?: string } - const body = (req.body || {}) as { key?: string, name?: string, topic?: string } + const body = (req.body || {}) as { + key?: string + name?: string + topic?: string + type?: string + entityType?: string | null + entityId?: number | null + entityUuid?: string | null + } return { key: params.roomKey || body.key, name: body.name, topic: body.topic, + type: body.type, + entityType: body.entityType, + entityId: body.entityId, + entityUuid: body.entityUuid, } } @@ -125,7 +137,7 @@ export default async function communicationRoutes(server: FastifyInstance) { server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => { try { const params = req.params as { roomKey: string } - return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey, params.roomKey) + return await matrix.getTenantRoomStatus(req.user.tenant_id, params.roomKey) } catch (err: any) { return handleMatrixError(req, reply, err, "Matrix room status failed") }