KI-AGENT: Matrix-Räume persistent verwalten

This commit is contained in:
2026-05-18 18:19:23 +02:00
parent 4d24e3a657
commit 6e14f48770
5 changed files with 294 additions and 23 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -14,6 +14,7 @@ export * from "./branches"
export * from "./checkexecutions" export * from "./checkexecutions"
export * from "./checks" export * from "./checks"
export * from "./citys" export * from "./citys"
export * from "./communication_rooms"
export * from "./contacts" export * from "./contacts"
export * from "./contracts" export * from "./contracts"
export * from "./contracttypes" export * from "./contracttypes"

View File

@@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from "node:crypto"
import { existsSync, readFileSync } from "node:fs" import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path" import { resolve } from "node:path"
import { FastifyInstance } from "fastify" 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 { and, eq } from "drizzle-orm"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
@@ -39,6 +39,10 @@ type MatrixTenantRoomOptions = {
key?: string key?: string
name?: string name?: string
topic?: string topic?: string
type?: string
entityType?: string | null
entityId?: number | null
entityUuid?: string | null
} }
type MatrixCachedValue<T = any> = { type MatrixCachedValue<T = any> = {
@@ -54,10 +58,11 @@ const matrixTenantSpaceCache = new Map<string, MatrixCachedValue>()
const matrixTenantRoomCache = new Map<string, MatrixCachedValue>() const matrixTenantRoomCache = new Map<string, MatrixCachedValue>()
let matrixServiceSessionCache: MatrixUserSession | null = null let matrixServiceSessionCache: MatrixUserSession | null = null
const defaultTenantRooms: Required<Pick<MatrixTenantRoomOptions, "key" | "name">>[] = [ const defaultTenantRooms: Required<Pick<MatrixTenantRoomOptions, "key" | "name" | "type">>[] = [
{ {
key: "allgemein", key: "allgemein",
name: "Allgemeiner Chat", name: "Allgemeiner Chat",
type: "general",
}, },
] ]
@@ -196,6 +201,10 @@ export function matrixService(server: FastifyInstance) {
key, key,
name, name,
topic, 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 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 ( const getTenantRoomStatus = async (
tenantId: number | null, tenantId: number | null,
roomKey: string, roomKey: string,
roomName: string roomName?: string
) => { ) => {
const tenant = await getCurrentTenant(tenantId) 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 { try {
const directoryEntry = await requestJson<{ const directoryEntry = await requestJson<{
room_id: string room_id: string
servers: string[] servers: string[]
}>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) }>(`${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 { return {
tenantId: tenant.id, tenantId: tenant.id,
tenantName: tenant.name, tenantName: tenant.name,
key: roomKey, id: roomMetadata.id,
name: roomName, key: roomMetadata.key,
name: roomMetadata.name,
topic: roomMetadata.topic,
type: roomMetadata.type,
entityType: roomMetadata.entityType,
entityId: roomMetadata.entityId,
entityUuid: roomMetadata.entityUuid,
alias, alias,
exists: true, exists: true,
roomId: directoryEntry.room_id, roomId: directoryEntry.room_id,
parentSpaceRoomId: roomMetadata.parentSpaceRoomId,
servers: directoryEntry.servers, servers: directoryEntry.servers,
} }
} catch (err: any) { } catch (err: any) {
@@ -647,11 +782,18 @@ export function matrixService(server: FastifyInstance) {
return { return {
tenantId: tenant.id, tenantId: tenant.id,
tenantName: tenant.name, tenantName: tenant.name,
key: roomKey, id: metadata.id,
name: roomName, key: metadata.key,
name: metadata.name,
topic: metadata.topic,
type: metadata.type,
entityType: metadata.entityType,
entityId: metadata.entityId,
entityUuid: metadata.entityUuid,
alias, alias,
exists: false, exists: false,
roomId: null, roomId: metadata.matrixRoomId,
parentSpaceRoomId: metadata.parentSpaceRoomId,
servers: [], servers: [],
} }
} }
@@ -667,6 +809,7 @@ export function matrixService(server: FastifyInstance) {
) => { ) => {
const tenant = await getCurrentTenant(tenantId) const tenant = await getCurrentTenant(tenantId)
const normalizedOptions = normalizeTenantRoomOptions(options) const normalizedOptions = normalizeTenantRoomOptions(options)
const metadata = await ensureTenantRoomMetadata(tenant, normalizedOptions)
const key = normalizedOptions.key const key = normalizedOptions.key
const name = normalizedOptions.name const name = normalizedOptions.name
const topic = (normalizedOptions.topic || `Allgemeiner Kommunikationsraum für ${tenant.name}`).trim() 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) const tenantSpace = await provisionCurrentTenantSpace(userId, tenant.id)
if (existing.exists) { if (existing.exists) {
await markTenantRoomProvisioned(metadata.id, {
matrixRoomId: existing.roomId,
matrixAlias: existing.alias,
parentSpaceRoomId: existing.parentSpaceRoomId,
})
const value = { const value = {
...existing, ...existing,
created: false, created: false,
@@ -748,10 +897,16 @@ export function matrixService(server: FastifyInstance) {
) )
const value = { const value = {
id: metadata.id,
tenantId: tenant.id, tenantId: tenant.id,
tenantName: tenant.name, tenantName: tenant.name,
key, key,
name, name,
topic,
type: metadata.type,
entityType: metadata.entityType,
entityId: metadata.entityId,
entityUuid: metadata.entityUuid,
alias: tenantRoomAlias(tenant, key), alias: tenantRoomAlias(tenant, key),
exists: true, exists: true,
created: true, created: true,
@@ -762,6 +917,12 @@ export function matrixService(server: FastifyInstance) {
serviceUserId: serviceLogin.matrixUserId, serviceUserId: serviceLogin.matrixUserId,
} }
await markTenantRoomProvisioned(metadata.id, {
matrixRoomId: value.roomId,
matrixAlias: value.alias,
parentSpaceRoomId: value.parentSpaceRoomId,
})
matrixTenantRoomCache.set(cacheKey, { matrixTenantRoomCache.set(cacheKey, {
exists: true, exists: true,
cachedUntil: Date.now() + 30 * 60 * 1000, cachedUntil: Date.now() + 30 * 60 * 1000,
@@ -805,26 +966,23 @@ export function matrixService(server: FastifyInstance) {
const listTenantRooms = async (tenantId: number | null) => { const listTenantRooms = async (tenantId: number | null) => {
const tenant = await getCurrentTenant(tenantId) const tenant = await getCurrentTenant(tenantId)
const rooms = new Map<string, any>()
for (const room of defaultTenantRooms) { 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 rooms = await server.db
const [cachedTenantId, roomKey] = cacheKey.split(":") .select()
if (cachedTenantId !== String(tenant.id) || !cachedRoom.value) continue .from(communicationRooms)
.where(and(
rooms.set(roomKey, { eq(communicationRooms.tenantId, tenant.id),
...cachedRoom.value, eq(communicationRooms.archived, false)
exists: true, ))
})
}
return { return {
tenantId: tenant.id, tenantId: tenant.id,
tenantName: tenant.name, 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") String(a.name || a.key).localeCompare(String(b.name || b.key), "de")
), ),
} }

View File

@@ -12,12 +12,24 @@ export default async function communicationRoutes(server: FastifyInstance) {
const roomOptionsFromRequest = (req: any) => { const roomOptionsFromRequest = (req: any) => {
const params = req.params as { roomKey?: string } 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 { return {
key: params.roomKey || body.key, key: params.roomKey || body.key,
name: body.name, name: body.name,
topic: body.topic, 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) => { server.get("/communication/matrix/rooms/:roomKey", async (req, reply) => {
try { try {
const params = req.params as { roomKey: string } 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) { } catch (err: any) {
return handleMatrixError(req, reply, err, "Matrix room status failed") return handleMatrixError(req, reply, err, "Matrix room status failed")
} }