From 358e40d74960f49dfa02c6ebb4ea4f0395f6fffb Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 8 Sep 2026 22:42:40 +0200 Subject: [PATCH] KI-AGENT: Matrix durch nativen FEDEO-Chat ersetzen --- .env.example | 47 - README.md | 22 +- .../migrations/0038_communication_rooms.sql | 3 - .../migrations/0043_communication_rooms.sql | 3 - .../0070_native_communication_chat.sql | 42 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/communication_messages.ts | 34 + .../db/schema/communication_room_members.ts | 26 + backend/db/schema/communication_room_reads.ts | 26 + backend/db/schema/communication_rooms.ts | 4 - backend/db/schema/index.ts | 3 + backend/src/index.ts | 2 - backend/src/modules/bootstrap.service.ts | 15 - .../src/modules/matrix-push-worker.service.ts | 377 --- backend/src/modules/matrix.service.ts | 2387 --------------- backend/src/modules/system-status.service.ts | 6 - backend/src/routes/admin.ts | 23 +- backend/src/routes/communication.ts | 1325 +++------ backend/src/utils/secrets.ts | 18 - backend/src/utils/tenantFullExport.ts | 122 +- docker-compose.selfhost.yml | 313 -- docker-compose.yml | 337 +-- docs-site/content/index.md | 1 - .../content/kommunikationslösung-matrix.md | 371 --- docs/README.md | 1 - docs/kommunikationslösung-matrix.md | 371 --- frontend/components/MainNav.vue | 4 +- frontend/nuxt.config.ts | 1 - frontend/package-lock.json | 131 +- frontend/package.json | 1 - frontend/pages/administration/system.vue | 1 - frontend/pages/communication/chat.vue | 2613 ++--------------- frontend/pages/communication/index.vue | 498 +--- matrix/README.md | 197 -- matrix/dev/element-config.json | 21 - matrix/well-known/client | 11 - matrix/well-known/server | 3 - mobile/app/(tabs)/_layout.tsx | 4 +- mobile/app/(tabs)/communication.tsx | 967 ++---- mobile/src/lib/api.ts | 270 +- scripts/selfhost-setup.sh | 93 +- website/app/assets/css/main.css | 30 +- website/app/pages/datenschutz.vue | 2 +- website/app/pages/impressum.vue | 2 +- website/app/pages/index.vue | 50 +- website/app/pages/kontakt.vue | 2 +- website/app/pages/zielgruppen.vue | 6 +- 47 files changed, 1027 insertions(+), 9766 deletions(-) create mode 100644 backend/db/migrations/0070_native_communication_chat.sql create mode 100644 backend/db/schema/communication_messages.ts create mode 100644 backend/db/schema/communication_room_members.ts create mode 100644 backend/db/schema/communication_room_reads.ts delete mode 100644 backend/src/modules/matrix-push-worker.service.ts delete mode 100644 backend/src/modules/matrix.service.ts delete mode 100644 docs-site/content/kommunikationslösung-matrix.md delete mode 100644 docs/kommunikationslösung-matrix.md delete mode 100644 matrix/README.md delete mode 100644 matrix/dev/element-config.json delete mode 100644 matrix/well-known/client delete mode 100644 matrix/well-known/server diff --git a/.env.example b/.env.example index 12f3189..155ef1a 100644 --- a/.env.example +++ b/.env.example @@ -117,50 +117,3 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN -FEDEO_BOOTSTRAP_MATRIX=true - -# FEDEO Matrix-Kommunikation -# -# Diese Werte werden von docker-compose.selfhost.yml für den integrierten -# Matrix-Stack gelesen. Für produktive Systeme müssen alle Geheimnisse ersetzt -# werden. - -MATRIX_SERVER_NAME=app.example.com - -MATRIX_POSTGRES_DB=synapse -MATRIX_POSTGRES_USER=synapse -MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password - -MATRIX_TURN_SHARED_SECRET=change-this-turn-secret - -LIVEKIT_KEY=fedeo-livekit -LIVEKIT_SECRET=change-this-livekit-secret-please-replace - -# Backend-Integration im Selfhost-Stack -MATRIX_HOMESERVER_URL=http://matrix-synapse:8008 -MATRIX_RTC_HOST=app.example.com -MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt -MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu -MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret -MATRIX_SERVICE_USER_LOCALPART=fedeo_service -NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element - -# Lokale Matrix-Entwicklung -MATRIX_DEV_SYNAPSE_PORT=8008 -MATRIX_DEV_ELEMENT_PORT=8080 -MATRIX_DEV_RTC_JWT_PORT=8081 -MATRIX_DEV_LIVEKIT_PORT=7880 -MATRIX_DEV_LIVEKIT_TCP_PORT=7881 -MATRIX_DEV_LIVEKIT_RTC_MIN_PORT=50000 -MATRIX_DEV_LIVEKIT_RTC_MAX_PORT=50100 -MATRIX_DEV_LIVEKIT_NODE_IP=127.0.0.1 -MATRIX_DEV_TURN_PORT=3478 -MATRIX_DEV_TURN_MIN_PORT=49160 -MATRIX_DEV_TURN_MAX_PORT=49200 - -# Lokale Backend-Integration gegen den Matrix-Entwicklungsstack -# MATRIX_HOMESERVER_URL=http://localhost:8008 -# MATRIX_RTC_JWT_URL=http://localhost:8081 -# MATRIX_LIVEKIT_URL=ws://localhost:7880 -# MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml -# NUXT_PUBLIC_MATRIX_ELEMENT_URL=http://localhost:8080 diff --git a/README.md b/README.md index 036b716..e6f64aa 100644 --- a/README.md +++ b/README.md @@ -239,33 +239,16 @@ FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN - -MATRIX_SERVER_NAME=app.example.com -MATRIX_POSTGRES_DB=synapse -MATRIX_POSTGRES_USER=synapse -MATRIX_POSTGRES_PASSWORD=change-this-matrix-db-password -MATRIX_TURN_SHARED_SECRET=change-this-turn-secret -MATRIX_HOMESERVER_URL=http://matrix-synapse:8008 -MATRIX_RTC_HOST=app.example.com -MATRIX_RTC_JWT_URL=https://app.example.com/livekit/jwt -MATRIX_LIVEKIT_URL=wss://app.example.com/livekit/sfu -MATRIX_REGISTRATION_SHARED_SECRET=change-this-matrix-registration-secret -MATRIX_SERVICE_USER_LOCALPART=fedeo_service -LIVEKIT_KEY=fedeo-livekit -LIVEKIT_SECRET=change-this-livekit-secret-please-replace -NUXT_PUBLIC_MATRIX_ELEMENT_URL=https://app.example.com/element ``` Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern. -## Docker Compose mit optionalem S3 und Matrix +## Docker Compose mit optionalem S3 Die Selfhost-Konfiguration wird im Betriebsverzeichnis als `docker-compose.yml` abgelegt. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. Seafile wird bewusst nicht im Standard-Compose-Stack gestartet. FEDEO kann später gegen einen extern betriebenen Seafile-Dienst sprechen; dafür bleiben `SEAFILE_BASE_URL`, `SEAFILE_INTERNAL_URL`, `SEAFILE_ADMIN_EMAIL` und `SEAFILE_ADMIN_PASSWORD` als generische Anbindungswerte vorgesehen. `FEDEO_FILE_BACKEND=s3` bleibt der Standard, bis die Backend-Integration für Seafile vollständig umgesetzt ist. -Der Matrix-Stack ist im Selfhost-Compose direkt enthalten. Er umfasst Synapse, eine eigene PostgreSQL-Datenbank für Synapse, Redis, `.well-known/matrix`, coturn, LiveKit, den LiveKit-JWT-Service und Element Web. Das einfache Selfhost-Setup nutzt nur `DOMAIN`: Synapse läuft unter `https://DOMAIN/_matrix`, Matrix-Well-Known unter `https://DOMAIN/.well-known/matrix`, LiveKit unter `https://DOMAIN/livekit/sfu`, der JWT-Service unter `https://DOMAIN/livekit/jwt` und Element Web unter `https://DOMAIN/element`. - Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest. ```yaml @@ -463,7 +446,6 @@ Im Deploy-Verzeichnis: docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d ``` -Synapse erzeugt `matrix/synapse/homeserver.yaml` beim ersten Start automatisch und aktualisiert die für FEDEO relevanten Werte aus der `.env`. `MATRIX_REGISTRATION_SHARED_SECRET` muss in der `.env` gesetzt und geheim bleiben, weil FEDEO damit Matrix-Nutzer provisioniert. Danach Status prufen: @@ -541,8 +523,6 @@ Regelmassig sichern: - `./postgres` - `./minio` falls MinIO lokal genutzt wird -- `./matrix/postgres` falls Matrix lokal betrieben wird -- `./matrix/synapse` falls Matrix lokal betrieben wird - `./traefik/letsencrypt/acme.json` - deine `.env` - deine dokumentierten Secret-Werte aus der `.env` oder deinem Secret-Management diff --git a/backend/db/migrations/0038_communication_rooms.sql b/backend/db/migrations/0038_communication_rooms.sql index 2206110..5e09605 100644 --- a/backend/db/migrations/0038_communication_rooms.sql +++ b/backend/db/migrations/0038_communication_rooms.sql @@ -8,9 +8,6 @@ CREATE TABLE "communication_rooms" ( "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, diff --git a/backend/db/migrations/0043_communication_rooms.sql b/backend/db/migrations/0043_communication_rooms.sql index ad6eea9..14cf31b 100644 --- a/backend/db/migrations/0043_communication_rooms.sql +++ b/backend/db/migrations/0043_communication_rooms.sql @@ -8,9 +8,6 @@ CREATE TABLE IF NOT EXISTS "communication_rooms" ( "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, diff --git a/backend/db/migrations/0070_native_communication_chat.sql b/backend/db/migrations/0070_native_communication_chat.sql new file mode 100644 index 0000000..618121d --- /dev/null +++ b/backend/db/migrations/0070_native_communication_chat.sql @@ -0,0 +1,42 @@ +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_room_id"; +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "matrix_alias"; +ALTER TABLE "communication_rooms" DROP COLUMN IF EXISTS "parent_space_room_id"; + +CREATE TABLE IF NOT EXISTS "communication_room_members" ( + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "communication_room_members_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id") +); + +CREATE INDEX IF NOT EXISTS "communication_room_members_user_idx" + ON "communication_room_members" ("user_id"); + +CREATE TABLE IF NOT EXISTS "communication_messages" ( + "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade, + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "author_user_id" uuid NOT NULL REFERENCES "auth_users"("id"), + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "communication_messages_room_message_idx" + ON "communication_messages" ("room_id", "id"); +CREATE INDEX IF NOT EXISTS "communication_messages_tenant_idx" + ON "communication_messages" ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "communication_room_reads" ( + "room_id" uuid NOT NULL REFERENCES "communication_rooms"("id") ON DELETE cascade, + "user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE cascade, + "last_read_message_id" bigint, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "communication_room_reads_room_id_user_id_pk" PRIMARY KEY ("room_id", "user_id") +); + +INSERT INTO "communication_room_members" ("room_id", "user_id") +SELECT room.id, tenant_user.user_id +FROM "communication_rooms" room +JOIN "auth_tenant_users" tenant_user ON tenant_user.tenant_id = room.tenant_id +WHERE room.type IN ('general', 'room') +ON CONFLICT DO NOTHING; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 5ecbfaf..9ba06fd 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -470,6 +470,13 @@ "when": 1788803000000, "tag": "0069_reset_email_entity_suggestions", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1788850800000, + "tag": "0070_native_communication_chat", + "breakpoints": true } ] } diff --git a/backend/db/schema/communication_messages.ts b/backend/db/schema/communication_messages.ts new file mode 100644 index 0000000..f25548e --- /dev/null +++ b/backend/db/schema/communication_messages.ts @@ -0,0 +1,34 @@ +import { bigint, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" +import { tenants } from "./tenants" + +export const communicationMessages = pgTable( + "communication_messages", + { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + authorUserId: uuid("author_user_id") + .notNull() + .references(() => authUsers.id), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + roomMessageIdx: index("communication_messages_room_message_idx").on(table.roomId, table.id), + tenantIdx: index("communication_messages_tenant_idx").on(table.tenantId), + }) +) + +export type CommunicationMessage = typeof communicationMessages.$inferSelect +export type NewCommunicationMessage = typeof communicationMessages.$inferInsert diff --git a/backend/db/schema/communication_room_members.ts b/backend/db/schema/communication_room_members.ts new file mode 100644 index 0000000..ecafa1e --- /dev/null +++ b/backend/db/schema/communication_room_members.ts @@ -0,0 +1,26 @@ +import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" + +export const communicationRoomMembers = pgTable( + "communication_room_members", + { + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + joinedAt: timestamp("joined_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roomId, table.userId] }), + userIdx: index("communication_room_members_user_idx").on(table.userId), + }) +) + +export type CommunicationRoomMember = typeof communicationRoomMembers.$inferSelect +export type NewCommunicationRoomMember = typeof communicationRoomMembers.$inferInsert diff --git a/backend/db/schema/communication_room_reads.ts b/backend/db/schema/communication_room_reads.ts new file mode 100644 index 0000000..31bbcf0 --- /dev/null +++ b/backend/db/schema/communication_room_reads.ts @@ -0,0 +1,26 @@ +import { bigint, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { communicationRooms } from "./communication_rooms" + +export const communicationRoomReads = pgTable( + "communication_room_reads", + { + roomId: uuid("room_id") + .notNull() + .references(() => communicationRooms.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade" }), + lastReadMessageId: bigint("last_read_message_id", { mode: "number" }), + readAt: timestamp("read_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roomId, table.userId] }), + }) +) + +export type CommunicationRoomRead = typeof communicationRoomReads.$inferSelect +export type NewCommunicationRoomRead = typeof communicationRoomReads.$inferInsert diff --git a/backend/db/schema/communication_rooms.ts b/backend/db/schema/communication_rooms.ts index 0302b25..b9cba4e 100644 --- a/backend/db/schema/communication_rooms.ts +++ b/backend/db/schema/communication_rooms.ts @@ -30,10 +30,6 @@ export const communicationRooms = pgTable( 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 }) diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index e6d1e5e..b37bb94 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -16,6 +16,9 @@ export * from "./checkexecutions" export * from "./checks" export * from "./citys" export * from "./communication_rooms" +export * from "./communication_room_members" +export * from "./communication_messages" +export * from "./communication_room_reads" export * from "./contacts" export * from "./contracts" export * from "./contracttypes" diff --git a/backend/src/index.ts b/backend/src/index.ts index cdaf991..ec7120e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -61,7 +61,6 @@ import {loadSecrets, secrets} from "./utils/secrets"; import {initMailer} from "./utils/mailer" import {initS3} from "./utils/s3"; import { runBootstrap } from "./modules/bootstrap.service"; -import { startMatrixPushWorker } from "./modules/matrix-push-worker.service"; import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service"; import { startDocumentImportWorker } from "./modules/document-import/document-import.worker"; @@ -89,7 +88,6 @@ async function main() { await app.register(dbPlugin); await app.register(servicesPlugin); await runBootstrap(app); - startMatrixPushWorker(app); startCentralServicesHeartbeat(app); startDocumentImportWorker(app); diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index a6bd9dc..d362e67 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -19,7 +19,6 @@ import { tenants, texttemplates, } from "../../db/schema" -import { matrixService } from "./matrix.service" const adminPermissions = [ "mcp.tokens.write", @@ -456,18 +455,4 @@ export async function runBootstrap(server: FastifyInstance) { await ensureTenantBaseData(server, tenant.id, adminUser.id) console.log("✅ Bootstrap-Grunddaten geprüft") - if (process.env.FEDEO_BOOTSTRAP_MATRIX === "true") { - try { - const matrix = matrixService(server) - await matrix.provisionTenantRoom(adminUser.id, tenant.id, { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }) - console.log("✅ Bootstrap-Matrix-Kommunikation geprüft") - } catch (err) { - console.error("❌ Bootstrap-Matrix-Kommunikation fehlgeschlagen:", err) - throw err - } - } } diff --git a/backend/src/modules/matrix-push-worker.service.ts b/backend/src/modules/matrix-push-worker.service.ts deleted file mode 100644 index 44cd35f..0000000 --- a/backend/src/modules/matrix-push-worker.service.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { createHash } from "node:crypto" -import type { FastifyInstance } from "fastify" -import { and, desc, eq, inArray, isNotNull, ne } from "drizzle-orm" -import { authProfiles, authTenantUsers, authUsers, communicationRooms, notificationsItems } from "../../db/schema" -import { matrixService } from "./matrix.service" -import { NotificationService, UserDirectory } from "./notification.service" - -type ChatRecipient = { - userId: string - email?: string | null - firstName?: string | null - lastName?: string | null - fullName?: string | null - matrixUserId?: string -} - -type MatrixPushWorkerEvent = { - at: string - type: string - roomKey?: string - roomId?: string | null - messageId?: string - sender?: string - targets?: number - created?: number - delivered?: number - failed?: number - error?: string -} - -const matrixPushWorkerState = { - enabled: false, - startedAt: null as string | null, - lastRunAt: null as string | null, - lastJoinAt: null as string | null, - lastJoinTotal: 0, - lastJoinJoined: 0, - lastJoinFailed: 0, - hasSyncToken: false, - lastSyncRooms: 0, - lastSyncMessages: 0, - lastMatchedRooms: 0, - lastNotificationsCreated: 0, - lastNotificationsDelivered: 0, - lastNotificationsFailed: 0, - lastError: null as string | null, - events: [] as MatrixPushWorkerEvent[], -} - -const rememberWorkerEvent = (event: MatrixPushWorkerEvent) => { - matrixPushWorkerState.events = [ - { - at: new Date().toISOString(), - ...event, - }, - ...matrixPushWorkerState.events, - ].slice(0, 25) -} - -export const getMatrixPushWorkerState = () => ({ - ...matrixPushWorkerState, - events: [...matrixPushWorkerState.events], -}) - -const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { - const rows = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return rows[0] || null -} - -const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => { - const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") - return name || user.email || "Benutzer" -} - -const directRoomKey = (firstUserId: string, secondUserId: string) => { - const hash = createHash("sha256") - .update([firstUserId, secondUserId].sort().join(":")) - .digest("hex") - .slice(0, 16) - - return `direct_${hash}` -} - -const mentionAliasesForUser = (user: ChatRecipient) => { - const name = displayUserName(user) - return Array.from(new Set([ - name, - user.fullName, - [user.firstName, user.lastName].filter(Boolean).join(" "), - user.firstName, - user.email, - ].filter(Boolean).map((value) => String(value).toLowerCase()))) -} - -const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => { - const normalizedText = text.toLowerCase() - - return recipients - .filter((recipient) => mentionAliasesForUser(recipient).some((alias) => - normalizedText.includes(`@${alias}`) - )) - .map((recipient) => recipient.userId) -} - -export function startMatrixPushWorker(server: FastifyInstance) { - if (process.env.MATRIX_PUSH_WORKER_DISABLED === "1") { - server.log.info("Matrix-Push-Worker ist deaktiviert") - return - } - - matrixPushWorkerState.enabled = true - matrixPushWorkerState.startedAt = new Date().toISOString() - rememberWorkerEvent({ at: new Date().toISOString(), type: "started" }) - - const matrix = matrixService(server) - const notifications = new NotificationService(server, getUserDirectory) - const intervalMs = Math.max(Number(process.env.MATRIX_PUSH_WORKER_INTERVAL_MS || 3000), 1000) - let since: string | undefined - let running = false - let stopped = false - let timer: ReturnType | undefined - let lastServiceJoinSyncAt = 0 - let errorBackoffMs = 0 - - const getTenantRecipients = async (tenantId: number) => { - const rows = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(eq(authTenantUsers.tenant_id, tenantId)) - - return await Promise.all(rows.map(async (row) => ({ - ...row, - matrixUserId: await matrix.matrixUserIdForUser(row.userId, tenantId), - }))) - } - - const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => { - const rows = await server.db - .select({ - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new") - )) - .orderBy(desc(notificationsItems.createdAt)) - .limit(200) - - return rows.some((row) => (row.payload as any)?.messageId === messageId) - } - - const recipientsForMessage = ( - room: typeof communicationRooms.$inferSelect, - recipients: ChatRecipient[], - senderUserId: string | null, - text: string - ) => { - const candidates = senderUserId - ? recipients.filter((recipient) => recipient.userId !== senderUserId) - : recipients - const mentioned = new Set(mentionedRecipientIds(text, candidates)) - const directRecipients = new Set() - - if (room.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { - directRecipients.add(room.entityUuid) - } else if (room.type === "direct" && senderUserId) { - candidates - .filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key) - .forEach((recipient) => directRecipients.add(recipient.userId)) - } - - return candidates - .filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId)) - .map((recipient) => ({ - ...recipient, - mentioned: mentioned.has(recipient.userId), - direct: directRecipients.has(recipient.userId), - })) - } - - const deliverMessageNotification = async ( - room: typeof communicationRooms.$inferSelect, - message: any, - recipients: ChatRecipient[] - ) => { - if (!message.id || message.own) return - - const sender = recipients.find((recipient) => recipient.matrixUserId === message.sender) || null - const text = message.body || message.attachment?.fileName || "Neue Nachricht" - const targets = recipientsForMessage(room, recipients, sender?.userId || null, text) - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "message_seen", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: targets.length, - }) - if (!targets.length) return - - const senderName = sender ? displayUserName(sender) : message.senderDisplayName || message.sender || "Matrix" - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - for (const target of targets) { - if (await hasChatNotificationForMessage(room.tenantId, target.userId, message.id)) { - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "notification_skipped_duplicate", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: 1, - }) - continue - } - - const result = await notifications.trigger({ - tenantId: room.tenantId, - userId: target.userId, - eventType: "communication.message.new", - title: target.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - matrixSender: message.sender, - mentioned: target.mentioned, - direct: target.direct, - }, - channels: ["inapp", "push"], - }) - matrixPushWorkerState.lastNotificationsCreated += result.created || 0 - matrixPushWorkerState.lastNotificationsDelivered += result.delivered || 0 - matrixPushWorkerState.lastNotificationsFailed += result.failed || 0 - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "notification_triggered", - roomKey: room.key, - roomId: room.matrixRoomId, - messageId: message.id, - sender: message.sender, - targets: 1, - created: result.created || 0, - delivered: result.delivered || 0, - failed: result.failed || 0, - }) - } - } - - const runOnce = async () => { - if (running || stopped) return - running = true - - try { - matrixPushWorkerState.lastRunAt = new Date().toISOString() - matrixPushWorkerState.lastError = null - matrixPushWorkerState.lastSyncRooms = 0 - matrixPushWorkerState.lastSyncMessages = 0 - matrixPushWorkerState.lastMatchedRooms = 0 - matrixPushWorkerState.lastNotificationsCreated = 0 - matrixPushWorkerState.lastNotificationsDelivered = 0 - matrixPushWorkerState.lastNotificationsFailed = 0 - - if (!lastServiceJoinSyncAt || Date.now() - lastServiceJoinSyncAt > 60_000) { - const joinResult = await matrix.syncServiceJoinedTenantRooms() - lastServiceJoinSyncAt = Date.now() - matrixPushWorkerState.lastJoinAt = new Date().toISOString() - matrixPushWorkerState.lastJoinTotal = joinResult.total - matrixPushWorkerState.lastJoinJoined = joinResult.joined - matrixPushWorkerState.lastJoinFailed = joinResult.failed - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "service_join_sync", - targets: joinResult.total, - delivered: joinResult.joined, - failed: joinResult.failed, - }) - if (joinResult.failed) { - console.warn("Matrix-Push-Worker: Service-User konnte nicht alle Räume joinen", { - total: joinResult.total, - joined: joinResult.joined, - failed: joinResult.failed, - }) - } - } - - const initial = !since - const sync = await matrix.syncServiceRoomEvents(since, initial) - since = sync.nextBatch || since - matrixPushWorkerState.hasSyncToken = Boolean(since) - matrixPushWorkerState.lastSyncRooms = sync.rooms?.length || 0 - matrixPushWorkerState.lastSyncMessages = (sync.rooms || []) - .reduce((sum: number, room: any) => sum + (room.messages?.length || 0), 0) - - if (!initial && sync.rooms?.length) { - const roomIds = sync.rooms.map((room: any) => room.roomId).filter(Boolean) - const rooms = roomIds.length - ? await server.db - .select() - .from(communicationRooms) - .where(and( - inArray(communicationRooms.matrixRoomId, roomIds), - ne(communicationRooms.archived, true), - isNotNull(communicationRooms.matrixRoomId) - )) - : [] - const roomsByMatrixId = new Map(rooms.map((room) => [room.matrixRoomId, room])) - matrixPushWorkerState.lastMatchedRooms = rooms.length - const recipientsByTenant = new Map() - - for (const syncedRoom of sync.rooms) { - const room = roomsByMatrixId.get(syncedRoom.roomId) - if (!room || !syncedRoom.messages?.length) continue - - if (!recipientsByTenant.has(room.tenantId)) { - recipientsByTenant.set(room.tenantId, await getTenantRecipients(room.tenantId)) - } - - const recipients = recipientsByTenant.get(room.tenantId) || [] - for (const message of syncedRoom.messages) { - await deliverMessageNotification(room, message, recipients) - } - } - } - errorBackoffMs = 0 - } catch (err) { - matrixPushWorkerState.lastError = err instanceof Error ? err.message : String(err) - const retryAfterMs = Number((err as any)?.retryAfterMs || (err as any)?.body?.retry_after_ms || 0) - errorBackoffMs = Math.min( - Math.max(retryAfterMs || (errorBackoffMs ? errorBackoffMs * 2 : 30_000), 30_000), - 5 * 60_000 - ) - rememberWorkerEvent({ - at: new Date().toISOString(), - type: "error", - error: matrixPushWorkerState.lastError, - }) - console.error("Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten", err) - server.log.error({ err }, "Matrix-Push-Worker konnte Matrix-Events nicht verarbeiten") - } finally { - running = false - if (!stopped) { - const nextDelay = errorBackoffMs || (since ? 0 : intervalMs) - timer = setTimeout(() => void runOnce(), nextDelay) - } - } - } - - timer = setTimeout(() => void runOnce(), intervalMs) - server.addHook("onClose", async () => { - stopped = true - if (timer) clearTimeout(timer) - }) -} diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts deleted file mode 100644 index c75f49e..0000000 --- a/backend/src/modules/matrix.service.ts +++ /dev/null @@ -1,2387 +0,0 @@ -import { createHash, createHmac, randomBytes } from "node:crypto" -import { existsSync, readFileSync } from "node:fs" -import { resolve } from "node:path" -import { FastifyInstance } from "fastify" -import { authProfiles, authTenantUsers, authUsers, communicationRooms, tenants } from "../../db/schema" -import { and, eq, isNotNull } from "drizzle-orm" -import { secrets } from "../utils/secrets" -import jwt from "jsonwebtoken" - -type MatrixErrorResponse = { - errcode?: string - error?: string - retry_after_ms?: number -} - -type MatrixRoomEvent = { - event_id: string - sender: string - origin_server_ts: number - type: string - redacts?: string - content?: { - body?: string - msgtype?: string - url?: string - info?: { - mimetype?: string - size?: number - } - "m.new_content"?: { - body?: string - msgtype?: string - } - "m.relates_to"?: { - event_id?: string - key?: string - rel_type?: string - "m.in_reply_to"?: { - event_id?: string - } - } - } -} - -type MatrixJoinedMembersResponse = { - joined: Record -} - -type MatrixRoomSearchResponse = { - search_categories?: { - room_events?: { - count?: number - results?: Array<{ - result?: MatrixRoomEvent & { - room_id?: string - } - }> - } - } -} - -type MatrixSyncResponse = { - next_batch?: string - rooms?: { - join?: Record - } -} - -type MatrixUserSession = { - accessToken: string - matrixUserId: string - validUntilMs: number -} - -type MatrixLoginTokenResponse = { - login_token: string - expires_in_ms: number -} - -type LiveKitGrant = { - roomJoin: boolean - room: string - canPublish: boolean - canSubscribe: boolean -} - -type MatrixTenantRoomOptions = { - key?: string - name?: string - topic?: string - type?: string - entityType?: string | null - entityId?: number | null - entityUuid?: string | null - inviteUserIds?: string[] -} - -type MatrixAttachmentInput = { - buffer: Buffer - filename: string - mimeType: string - size: number -} - -type MatrixMessageOptions = { - replyToEventId?: string -} - -type MatrixCachedValue = { - exists: true - cachedUntil: number - value: T -} - -const matrixUserSessionCache = new Map() -const matrixJoinedRoomCache = new Map() -const matrixProvisionedUserCache = new Map() -const matrixTenantSpaceCache = new Map() -const matrixTenantRoomCache = new Map() -let matrixServiceSessionCache: MatrixUserSession | null = null - -const defaultTenantRooms: Required>[] = [ - { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }, -] - -const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") -const readLocalDevRegistrationSharedSecret = () => { - if (process.env.NODE_ENV === "production") return "" - - const candidates = [ - resolve(process.cwd(), "../matrix/dev/synapse/homeserver.yaml"), - resolve(process.cwd(), "matrix/dev/synapse/homeserver.yaml"), - ] - - for (const candidate of candidates) { - if (!existsSync(candidate)) continue - - const content = readFileSync(candidate, "utf8") - const match = content.match(/^registration_shared_secret:\s*["']?(.+?)["']?\s*$/m) - - if (match?.[1]) { - return match[1] - } - } - - return "" -} - -const normalizeMatrixLocalpartSeed = (value: string) => { - const normalized = value - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/ä/g, "a") - .replace(/ö/g, "o") - .replace(/ü/g, "u") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9._=-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^[._=-]+|[._=-]+$/g, "") - - return normalized || "user" -} - -const normalizeMatrixAliasSeed = (value: string) => - normalizeMatrixLocalpartSeed(value) - .replace(/[.=]/g, "_") - .replace(/_+/g, "_") - -export function matrixService(server: FastifyInstance) { - const homeserverUrl = () => - trimTrailingSlash( - process.env.MATRIX_HOMESERVER_URL || - secrets.MATRIX_HOMESERVER_URL || - "http://localhost:8008" - ) - - const serverName = () => - process.env.MATRIX_SERVER_NAME || - secrets.MATRIX_SERVER_NAME || - "localhost" - - const registrationSharedSecret = () => - process.env.MATRIX_REGISTRATION_SHARED_SECRET || - secrets.MATRIX_REGISTRATION_SHARED_SECRET || - readLocalDevRegistrationSharedSecret() || - "" - - const rtcHost = () => - process.env.MATRIX_RTC_HOST || - secrets.MATRIX_RTC_HOST || - "call.fedeo.de" - - const rtcJwtUrl = () => - process.env.MATRIX_RTC_JWT_URL || - secrets.MATRIX_RTC_JWT_URL || - (process.env.NODE_ENV === "production" - ? `https://${rtcHost()}/livekit/jwt` - : `http://localhost:${process.env.MATRIX_DEV_RTC_JWT_PORT || "8081"}`) - - const livekitUrl = () => - process.env.MATRIX_LIVEKIT_URL || - secrets.MATRIX_LIVEKIT_URL || - (process.env.NODE_ENV === "production" - ? `wss://${rtcHost()}/livekit/sfu` - : `ws://localhost:${process.env.MATRIX_DEV_LIVEKIT_PORT || "7880"}`) - - const livekitKey = () => - process.env.LIVEKIT_KEY || - secrets.LIVEKIT_KEY || - (process.env.NODE_ENV === "production" ? "" : "devkey") - - const livekitSecret = () => - process.env.LIVEKIT_SECRET || - secrets.LIVEKIT_SECRET || - (process.env.NODE_ENV === "production" ? "" : "devsecret-local-matrix-stack-32-chars") - - const serviceUserLocalpart = () => - process.env.MATRIX_SERVICE_USER_LOCALPART || - secrets.MATRIX_SERVICE_USER_LOCALPART || - "fedeo_service" - - const serviceUserPassword = () => - createHmac("sha256", registrationSharedSecret()) - .update(`${serverName()}:fedeo-service-user`) - .digest("base64url") - - const getUserIdentitySeed = async (userId: string, tenantId: number | null) => { - const [user] = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - if (user?.email) { - return user.email.split("@")[0] || user.email - } - - if (tenantId) { - const displayName = await getCurrentUserDisplayName(userId, tenantId) - if (displayName && !displayName.startsWith("@")) { - return displayName - } - } - - return "user" - } - - const matrixLocalpartForUser = async (userId: string, tenantId: number | null) => { - const seed = normalizeMatrixLocalpartSeed(await getUserIdentitySeed(userId, tenantId)) - const hash = createHash("sha256").update(userId).digest("hex").slice(0, 8) - return `${seed}_${hash}` - } - - const matrixUserIdForUser = async (userId: string, tenantId: number | null) => - `@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}` - - const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => { - const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - return `fedeo_${seed}_${tenant.id}` - } - - const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) => - `#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}` - - const tenantRoomAliasLocalpart = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string - ) => { - const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - const roomSeed = normalizeMatrixAliasSeed(roomKey) - return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` - } - - const tenantRoomAlias = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string - ) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${serverName()}` - - const matrixIdentifierServerName = (value?: string | null) => { - const match = value?.match(/^[!#@][^:]+:(.+)$/) - return match?.[1] || null - } - - const belongsToCurrentMatrixServer = (value?: string | null) => { - const identifierServerName = matrixIdentifierServerName(value) - return !identifierServerName || identifierServerName === serverName() - } - - const normalizeTenantRoomOptions = (options: MatrixTenantRoomOptions = {}) => { - const fallbackName = options.key || options.name || "Allgemeiner Chat" - const key = normalizeMatrixAliasSeed(options.key || fallbackName) - const name = (options.name || fallbackName).trim() || "Allgemeiner Chat" - const topic = options.topic?.trim() - - return { - key, - name, - topic, - type: options.type || "room", - entityType: options.entityType || null, - entityId: options.entityId || null, - entityUuid: options.entityUuid || null, - inviteUserIds: options.inviteUserIds || [], - } - } - - const buildSharedSecretMac = ( - nonce: string, - username: string, - password: string, - admin: boolean - ) => { - const hmac = createHmac("sha1", registrationSharedSecret()) - hmac.update(nonce) - hmac.update("\0") - hmac.update(username) - hmac.update("\0") - hmac.update(password) - hmac.update("\0") - hmac.update(admin ? "admin" : "notadmin") - return hmac.digest("hex") - } - - const registerWithSharedSecret = async ( - username: string, - password: string, - admin: boolean - ) => { - const nonceResponse = await requestJson<{ nonce: string }>( - `${homeserverUrl()}/_synapse/admin/v1/register` - ) - const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin) - - return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nonce: nonceResponse.nonce, - username, - password, - admin, - mac, - }), - }) - } - - const loginMatrixUser = async (username: string, password: string) => { - return requestJson<{ - access_token: string - user_id: string - }>(`${homeserverUrl()}/_matrix/client/v3/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: username, - }, - password, - }), - }) - } - - const ensureServiceAccessToken = async () => { - if (!registrationSharedSecret()) { - throw Object.assign( - new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), - { statusCode: 503 } - ) - } - - if (matrixServiceSessionCache && matrixServiceSessionCache.validUntilMs > Date.now() + 60_000) { - return matrixServiceSessionCache - } - - const username = serviceUserLocalpart() - const password = serviceUserPassword() - - try { - const login = await loginMatrixUser(username, password) - matrixServiceSessionCache = { - accessToken: login.access_token, - matrixUserId: login.user_id, - validUntilMs: Date.now() + 30 * 60 * 1000, - } - - return matrixServiceSessionCache - } catch (loginErr: any) { - if (loginErr.statusCode === 429) throw loginErr - } - - try { - await registerWithSharedSecret(username, password, true) - } catch (registerErr: any) { - if (registerErr.errcode !== "M_USER_IN_USE") throw registerErr - } - const login = await loginMatrixUser(username, password) - matrixServiceSessionCache = { - accessToken: login.access_token, - matrixUserId: login.user_id, - validUntilMs: Date.now() + 30 * 60 * 1000, - } - - return matrixServiceSessionCache - } - - const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { - if (tenantId) { - const [profile] = await server.db - .select({ - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - }) - .from(authProfiles) - .where(and( - eq(authProfiles.user_id, userId), - eq(authProfiles.tenant_id, tenantId) - )) - .limit(1) - - const profileName = [profile?.firstName, profile?.lastName] - .filter(Boolean) - .join(" ") - .trim() - - if (profileName) return profileName - } - - const [user] = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return user?.email || await matrixUserIdForUser(userId, tenantId) - } - - const requestJson = async (url: string, init?: RequestInit): Promise => { - const response = await fetch(url, init) - const text = await response.text() - const body = text ? JSON.parse(text) : {} - - if (!response.ok) { - const error = body as MatrixErrorResponse - throw Object.assign( - new Error(error.error || `Matrix request failed with ${response.status}`), - { - statusCode: response.status, - errcode: error.errcode, - retryAfterMs: error.retry_after_ms, - body, - } - ) - } - - return body as T - } - - const requestMatrixJson = async (path: string, accessToken: string, init?: RequestInit): Promise => { - return requestJson(`${homeserverUrl()}${path}`, { - ...init, - headers: { - ...(init?.headers || {}), - Authorization: `Bearer ${accessToken}`, - }, - }) - } - - const mxcToMediaPath = (mxcUri: string) => { - const match = mxcUri.match(/^mxc:\/\/([^/]+)\/(.+)$/) - if (!match) { - throw Object.assign( - new Error("Ungültige Matrix-Media-URI"), - { statusCode: 400 } - ) - } - - return `/_matrix/media/v3/download/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}` - } - - const matrixMediaUrl = (mxcUri: string) => `${homeserverUrl()}${mxcToMediaPath(mxcUri)}` - - const attachmentFromEvent = (event: MatrixRoomEvent) => { - const msgtype = event.content?.msgtype || "m.text" - - if (!["m.file", "m.image"].includes(msgtype) || !event.content?.url) { - return null - } - - const mimeType = event.content.info?.mimetype || "application/octet-stream" - - return { - type: msgtype === "m.image" ? "image" : "file", - url: event.content.url, - fileName: event.content.body || "Anhang", - mimeType, - size: event.content.info?.size || 0, - previewUrl: msgtype === "m.image" ? matrixMediaUrl(event.content.url) : null, - downloadUrl: matrixMediaUrl(event.content.url), - isImage: msgtype === "m.image" || mimeType.startsWith("image/"), - } - } - - const createAccessTokenForUser = async (userId: string, tenantId: number | null) => { - const matrixUserId = await matrixUserIdForUser(userId, tenantId) - const cacheKey = `${tenantId || "global"}:${userId}` - const cachedSession = matrixUserSessionCache.get(cacheKey) - - if (cachedSession && cachedSession.validUntilMs > Date.now() + 60_000) { - return cachedSession - } - - await provisionCurrentUser(userId, tenantId) - - const serviceLogin = await ensureServiceAccessToken() - const validUntilMs = Date.now() + 30 * 60 * 1000 - - const login = await requestMatrixJson<{ access_token: string }>( - `/_synapse/admin/v1/users/${encodeURIComponent(matrixUserId)}/login`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ valid_until_ms: validUntilMs }), - } - ) - - const session = { - accessToken: login.access_token, - matrixUserId, - validUntilMs, - } - - matrixUserSessionCache.set(cacheKey, session) - return session - } - - const getStatus = async () => { - const configured = Boolean(homeserverUrl() && serverName()) - - if (!configured) { - return { - configured: false, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: false, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - } - } - - try { - const versions = await requestJson<{ versions: string[] }>( - `${homeserverUrl()}/_matrix/client/versions` - ) - - return { - configured: true, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: true, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - versions: versions.versions, - } - } catch (err: any) { - return { - configured: true, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - provisioningConfigured: Boolean(registrationSharedSecret()), - reachable: false, - calls: { - provider: "matrixrtc-livekit", - configured: Boolean(rtcJwtUrl() && livekitUrl()), - rtcHost: rtcHost(), - rtcJwtUrl: rtcJwtUrl(), - livekitUrl: livekitUrl(), - }, - error: err.message, - } - } - } - - const provisionCurrentUser = async (userId: string, tenantId: number | null) => { - if (!registrationSharedSecret()) { - throw Object.assign( - new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), - { statusCode: 503 } - ) - } - - const username = await matrixLocalpartForUser(userId, tenantId) - const matrixUserId = await matrixUserIdForUser(userId, tenantId) - const displayName = await getCurrentUserDisplayName(userId, tenantId) - const cacheKey = `${tenantId || "global"}:${userId}` - const cachedUntil = matrixProvisionedUserCache.get(cacheKey) - - if (cachedUntil && cachedUntil > Date.now()) { - return { - matrixUserId, - localpart: username, - displayName, - created: false, - alreadyExisted: true, - } - } - - const password = randomBytes(32).toString("base64url") - - try { - await registerWithSharedSecret(username, password, false) - matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) - - return { - matrixUserId, - localpart: username, - displayName, - created: true, - alreadyExisted: false, - } - } catch (err: any) { - if (err.errcode === "M_USER_IN_USE") { - matrixProvisionedUserCache.set(cacheKey, Date.now() + 30 * 60 * 1000) - - return { - matrixUserId, - localpart: username, - displayName, - created: false, - alreadyExisted: true, - } - } - - throw err - } - } - - const getCurrentTenant = async (tenantId: number | null) => { - if (!tenantId) { - throw Object.assign( - new Error("No active tenant selected"), - { statusCode: 400 } - ) - } - - const [tenant] = await server.db - .select({ - id: tenants.id, - name: tenants.name, - short: tenants.short, - }) - .from(tenants) - .where(eq(tenants.id, tenantId)) - .limit(1) - - if (!tenant) { - throw Object.assign( - new Error("Tenant not found"), - { statusCode: 404 } - ) - } - - return tenant - } - - const getTenantSpaceStatus = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const alias = tenantSpaceAlias(tenant) - - try { - const directoryEntry = await requestJson<{ - room_id: string - servers: string[] - }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) - - return { - tenantId: tenant.id, - tenantName: tenant.name, - alias, - exists: true, - roomId: directoryEntry.room_id, - servers: directoryEntry.servers, - } - } catch (err: any) { - if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { - return { - tenantId: tenant.id, - tenantName: tenant.name, - alias, - exists: false, - roomId: null, - servers: [], - } - } - - throw err - } - } - - const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const cacheKey = String(tenant.id) - const cachedSpace = matrixTenantSpaceCache.get(cacheKey) - - if (cachedSpace?.exists && cachedSpace.cachedUntil > Date.now()) { - return cachedSpace.value - } - - const existing = await getTenantSpaceStatus(tenant.id) - const userAccount = await provisionCurrentUser(userId, tenant.id) - - if (existing.exists) { - const value = { - ...existing, - created: false, - alreadyExisted: true, - invitedUserId: userAccount.matrixUserId, - } - - matrixTenantSpaceCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const serviceLogin = await ensureServiceAccessToken() - const aliasLocalpart = tenantSpaceAliasLocalpart(tenant) - const createdRoom = await requestMatrixJson<{ room_id: string }>( - "/_matrix/client/v3/createRoom", - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - creation_content: { - type: "m.space", - }, - name: `FEDEO · ${tenant.name}`, - topic: `Kommunikationsbereich für ${tenant.name}`, - preset: "private_chat", - visibility: "private", - room_alias_name: aliasLocalpart, - invite: [userAccount.matrixUserId], - initial_state: [ - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "invited", - }, - }, - ], - }), - } - ) - - const value = { - tenantId: tenant.id, - tenantName: tenant.name, - alias: tenantSpaceAlias(tenant), - exists: true, - created: true, - alreadyExisted: false, - roomId: createdRoom.room_id, - invitedUserId: userAccount.matrixUserId, - serviceUserId: serviceLogin.matrixUserId, - } - - matrixTenantSpaceCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - 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) - const expectedAlias = tenantRoomAlias(tenant, normalizedOptions.key) - - if (existing) { - const hasStaleMatrixRoomId = !belongsToCurrentMatrixServer(existing.matrixRoomId) - const hasStaleMatrixAlias = !belongsToCurrentMatrixServer(existing.matrixAlias) - const shouldUpdate = - hasStaleMatrixRoomId || - hasStaleMatrixAlias || - (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, - matrixRoomId: hasStaleMatrixRoomId ? null : existing.matrixRoomId, - matrixAlias: hasStaleMatrixAlias ? expectedAlias : existing.matrixAlias, - parentSpaceRoomId: hasStaleMatrixRoomId ? null : existing.parentSpaceRoomId, - 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: expectedAlias, - }) - .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 - ) => { - const tenant = await getCurrentTenant(tenantId) - 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, - 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) { - if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { - return { - tenantId: tenant.id, - tenantName: tenant.name, - 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: belongsToCurrentMatrixServer(metadata.matrixRoomId) ? metadata.matrixRoomId : null, - parentSpaceRoomId: belongsToCurrentMatrixServer(metadata.parentSpaceRoomId) ? metadata.parentSpaceRoomId : null, - servers: [], - } - } - - throw err - } - } - - const provisionTenantRoom = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - 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() - const cacheKey = `${tenant.id}:${key}` - const cachedRoom = matrixTenantRoomCache.get(cacheKey) - - if (cachedRoom?.exists && cachedRoom.cachedUntil > Date.now()) { - return cachedRoom.value - } - - const existing = await getTenantRoomStatus(tenant.id, key, name) - const userAccount = await provisionCurrentUser(userId, tenant.id) - const invitedMatrixUserIds = await matrixUserIdsForInvitees(userId, tenant.id, normalizedOptions.inviteUserIds || []) - 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, - alreadyExisted: true, - parentSpaceRoomId: tenantSpace.roomId, - invitedUserId: userAccount.matrixUserId, - } - - await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds) - - matrixTenantRoomCache.set(cacheKey, { - exists: true, - cachedUntil: Date.now() + 30 * 60 * 1000, - value, - }) - return value - } - - const serviceLogin = await ensureServiceAccessToken() - const createdRoom = await requestMatrixJson<{ room_id: string }>( - "/_matrix/client/v3/createRoom", - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name, - topic, - preset: "private_chat", - visibility: "private", - room_alias_name: tenantRoomAliasLocalpart(tenant, key), - invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])), - initial_state: [ - { - type: "m.room.history_visibility", - state_key: "", - content: { - history_visibility: "invited", - }, - }, - { - type: "m.space.parent", - state_key: tenantSpace.roomId, - content: { - via: [serverName()], - canonical: true, - }, - }, - ], - }), - } - ) - - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(tenantSpace.roomId)}/state/m.space.child/${encodeURIComponent(createdRoom.room_id)}`, - serviceLogin.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - via: [serverName()], - suggested: true, - order: "10", - }), - } - ) - - 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, - alreadyExisted: false, - roomId: createdRoom.room_id, - parentSpaceRoomId: tenantSpace.roomId, - invitedUserId: userAccount.matrixUserId, - 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, - value, - }) - return value - } - - const matrixUserIdsForInvitees = async ( - currentUserId: string, - tenantId: number, - inviteUserIds: string[] - ) => { - const uniqueUserIds = Array.from(new Set(inviteUserIds.filter((id) => id && id !== currentUserId))) - - return await Promise.all(uniqueUserIds.map(async (inviteUserId) => { - const account = await provisionCurrentUser(inviteUserId, tenantId) - return account.matrixUserId - })) - } - - const inviteUsersToRoom = async (roomId: string | null, matrixUserIds: string[]) => { - if (!roomId || !matrixUserIds.length) return - - const serviceLogin = await ensureServiceAccessToken() - - for (const matrixUserId of matrixUserIds) { - try { - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: matrixUserId }), - } - ) - } catch (err: any) { - if (err.statusCode === 403 || err.statusCode === 400) continue - throw err - } - } - } - - const ensureServiceUserJoinedRoom = async (room: { roomId?: string | null; alias?: string | null }) => { - const target = room.roomId || room.alias - if (!target) return { ok: false, status: "missing_room" } - if (!belongsToCurrentMatrixServer(target)) { - return { ok: false, status: "stale_room", roomId: target } - } - - const serviceLogin = await ensureServiceAccessToken() - - try { - await requestMatrixJson( - `/_synapse/admin/v1/join/${encodeURIComponent(target)}`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user_id: serviceLogin.matrixUserId }), - } - ) - - return { ok: true, status: "joined_admin", roomId: target } - } catch (adminErr: any) { - try { - await requestMatrixJson( - `/_matrix/client/v3/join/${encodeURIComponent(target)}`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { ok: true, status: "joined_client", roomId: target } - } catch (clientErr: any) { - return { - ok: false, - status: "failed", - roomId: target, - error: clientErr.message || adminErr.message || "Service-Join fehlgeschlagen", - } - } - } - } - - const syncServiceJoinedTenantRooms = async () => { - const rooms = await server.db - .select({ - id: communicationRooms.id, - tenantId: communicationRooms.tenantId, - key: communicationRooms.key, - name: communicationRooms.name, - matrixRoomId: communicationRooms.matrixRoomId, - matrixAlias: communicationRooms.matrixAlias, - }) - .from(communicationRooms) - .where(and( - eq(communicationRooms.archived, false), - isNotNull(communicationRooms.matrixRoomId) - )) - - const results = [] - for (const room of rooms) { - const result = await ensureServiceUserJoinedRoom({ - roomId: room.matrixRoomId, - alias: room.matrixAlias, - }) - results.push({ - roomId: room.matrixRoomId, - roomKey: room.key, - roomName: room.name, - ...result, - }) - } - - return { - total: results.length, - joined: results.filter((result) => result.ok).length, - failed: results.filter((result) => !result.ok).length, - results, - } - } - - const ensureCurrentUserJoinedRoom = async ( - userId: string, - tenantId: number | null, - room: { roomId: string, alias: string } - ) => { - const session = await createAccessTokenForUser(userId, tenantId) - const joinCacheKey = `${session.matrixUserId}:${room.roomId || room.alias}` - const joinedUntil = matrixJoinedRoomCache.get(joinCacheKey) - - if (joinedUntil && joinedUntil > Date.now()) { - return session - } - - await requestMatrixJson( - `/_matrix/client/v3/join/${encodeURIComponent(room.roomId || room.alias)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - matrixJoinedRoomCache.set(joinCacheKey, Date.now() + 30 * 60 * 1000) - return session - } - - const listTenantRooms = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - - for (const room of defaultTenantRooms) { - await ensureTenantRoomMetadata(tenant, room) - } - - 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: rooms.map((room) => roomMetadataToApi(tenant, room)).sort((a, b) => - String(a.name || a.key).localeCompare(String(b.name || b.key), "de") - ), - } - } - - const getTenantRoomMessages = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - const response = await requestMatrixJson<{ - chunk: MatrixRoomEvent[] - start?: string - end?: string - }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/messages?dir=b&limit=50`, - session.accessToken - ) - const members = await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ) - - const replacementByEventId = new Map() - const reactionsByEventId = new Map>() - - for (const event of response.chunk) { - const relation = event.content?.["m.relates_to"] - - if ( - event.type === "m.room.message" && - relation?.rel_type === "m.replace" && - relation.event_id - ) { - replacementByEventId.set(relation.event_id, event) - } - - if ( - event.type === "m.reaction" && - relation?.rel_type === "m.annotation" && - relation.event_id && - relation.key - ) { - const eventReactions = reactionsByEventId.get(relation.event_id) || new Map() - const reaction = eventReactions.get(relation.key) || { - key: relation.key, - count: 0, - own: false, - } - - reaction.count += 1 - reaction.own = reaction.own || event.sender === session.matrixUserId - eventReactions.set(relation.key, reaction) - reactionsByEventId.set(relation.event_id, eventReactions) - } - } - - const messages = response.chunk - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => { - const replacement = replacementByEventId.get(event.event_id) - const content = replacement?.content?.["m.new_content"] || replacement?.content || event.content - - return { - id: event.event_id, - sender: event.sender, - senderDisplayName: members.joined[event.sender]?.display_name || event.sender, - body: content?.body || "", - attachment: attachmentFromEvent({ ...event, content }), - timestamp: replacement?.origin_server_ts || event.origin_server_ts, - own: event.sender === session.matrixUserId, - edited: Boolean(replacement), - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - reactions: Array.from(reactionsByEventId.get(event.event_id)?.values() || []), - } - }) - .reverse() - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - messages, - } - } - - const getTenantRoomMembers = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const members = await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - members: Object.entries(members.joined).map(([matrixUserId, member]) => ({ - matrixUserId, - displayName: member.display_name || matrixUserId, - avatarUrl: member.avatar_url || null, - own: matrixUserId === session.matrixUserId, - })), - } - } - - const searchTenantRoomMessages = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - query: string - ) => { - const searchTerm = query.trim() - - if (searchTerm.length < 2) { - return { - roomId: "", - alias: "", - key: options.key || "allgemein", - name: options.name || options.key || "Chat", - count: 0, - results: [], - } - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const [response, members] = await Promise.all([ - requestMatrixJson( - "/_matrix/client/v3/search", - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - search_categories: { - room_events: { - search_term: searchTerm, - keys: ["content.body"], - order_by: "recent", - filter: { - limit: 25, - rooms: [room.roomId], - }, - }, - }, - }), - } - ), - requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/joined_members`, - session.accessToken - ), - ]) - - const roomEvents = response.search_categories?.room_events - const results = (roomEvents?.results || []) - .map((item) => item.result) - .filter((event): event is MatrixRoomEvent & { room_id?: string } => - Boolean( - event?.event_id && - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") - ) - ) - .map((event) => ({ - id: event.event_id, - roomId: event.room_id || room.roomId, - key: room.key, - sender: event.sender, - senderDisplayName: members.joined[event.sender]?.display_name || event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === session.matrixUserId, - })) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - count: roomEvents?.count || results.length, - results, - } - } - - const syncTenantRoomEvents = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - since?: string, - initial = false - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const filter = { - room: { - rooms: [room.roomId], - timeline: { - limit: 30, - }, - }, - presence: { - types: [], - }, - account_data: { - types: [], - }, - } - const params = new URLSearchParams({ - timeout: since && !initial ? "25000" : "0", - filter: JSON.stringify(filter), - }) - - if (since) params.set("since", since) - - const response = await requestMatrixJson( - `/_matrix/client/v3/sync?${params.toString()}`, - session.accessToken - ) - const joinedRoom = response.rooms?.join?.[room.roomId] - const timelineEvents = joinedRoom?.timeline?.events || [] - const stateEvents = joinedRoom?.state?.events || [] - - if (initial) { - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - nextBatch: response.next_batch || since || "", - messages: [], - replacements: [], - reactions: [], - redactions: [], - membersChanged: false, - } - } - - const messages = timelineEvents - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => ({ - id: event.event_id, - sender: event.sender, - senderDisplayName: event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === session.matrixUserId, - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - reactions: [], - })) - - const replacements = timelineEvents - .filter((event) => - event.type === "m.room.message" && - event.content?.["m.relates_to"]?.rel_type === "m.replace" && - Boolean(event.content?.["m.relates_to"]?.event_id) - ) - .map((event) => ({ - id: event.event_id, - targetEventId: event.content?.["m.relates_to"]?.event_id, - body: event.content?.["m.new_content"]?.body || event.content?.body || "", - timestamp: event.origin_server_ts, - sender: event.sender, - own: event.sender === session.matrixUserId, - })) - - const reactions = timelineEvents - .filter((event) => - event.type === "m.reaction" && - event.content?.["m.relates_to"]?.rel_type === "m.annotation" && - Boolean(event.content?.["m.relates_to"]?.event_id) && - Boolean(event.content?.["m.relates_to"]?.key) - ) - .map((event) => ({ - id: event.event_id, - targetEventId: event.content?.["m.relates_to"]?.event_id, - key: event.content?.["m.relates_to"]?.key, - sender: event.sender, - own: event.sender === session.matrixUserId, - })) - - const redactions = timelineEvents - .filter((event) => event.type === "m.room.redaction" && Boolean(event.redacts)) - .map((event) => ({ - id: event.event_id, - targetEventId: event.redacts, - sender: event.sender, - timestamp: event.origin_server_ts, - })) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - nextBatch: response.next_batch || since || "", - messages, - replacements, - reactions, - redactions, - membersChanged: [...timelineEvents, ...stateEvents].some((event) => event.type === "m.room.member"), - } - } - - const syncServiceRoomEvents = async (since?: string, initial = false) => { - const service = await ensureServiceAccessToken() - const filter = { - room: { - timeline: { - limit: 50, - }, - }, - presence: { - types: [], - }, - account_data: { - types: [], - }, - } - const params = new URLSearchParams({ - timeout: since && !initial ? "25000" : "0", - filter: JSON.stringify(filter), - }) - - if (since) params.set("since", since) - - const response = await requestMatrixJson( - `/_matrix/client/v3/sync?${params.toString()}`, - service.accessToken - ) - const joinedRooms = response.rooms?.join || {} - - return { - nextBatch: response.next_batch || since || "", - serviceUserId: service.matrixUserId, - rooms: Object.entries(joinedRooms).map(([roomId, joinedRoom]) => { - const timelineEvents = joinedRoom.timeline?.events || [] - const messages = initial - ? [] - : timelineEvents - .filter((event) => - event.type === "m.room.message" && - ["m.text", "m.file", "m.image"].includes(event.content?.msgtype || "") && - event.content?.["m.relates_to"]?.rel_type !== "m.replace" - ) - .map((event) => ({ - id: event.event_id, - roomId, - sender: event.sender, - senderDisplayName: event.sender, - body: event.content?.body || "", - attachment: attachmentFromEvent(event), - timestamp: event.origin_server_ts, - own: event.sender === service.matrixUserId, - replyToEventId: event.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id || null, - })) - - return { - roomId, - messages, - membersChanged: [...timelineEvents, ...(joinedRoom.state?.events || [])] - .some((event) => event.type === "m.room.member"), - } - }), - } - } - - const sendTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - text: string, - messageOptions: MatrixMessageOptions = {} - ) => { - const message = text.trim() - - if (!message) { - throw Object.assign( - new Error("Message text is required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - - const content: Record = { - msgtype: "m.text", - body: message, - } - - if (messageOptions.replyToEventId) { - content["m.relates_to"] = { - "m.in_reply_to": { - event_id: messageOptions.replyToEventId, - }, - } - } - - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(content), - } - ) - - return { - id: response.event_id, - sender: session.matrixUserId, - senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), - body: message, - timestamp: Date.now(), - own: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - replyToEventId: messageOptions.replyToEventId || null, - reactions: [], - } - } - - const sendTenantRoomReaction = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string, - key: string - ) => { - const reactionKey = key.trim() - if (!eventId || !reactionKey) { - throw Object.assign( - new Error("Reaction target and key are required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - "m.relates_to": { - rel_type: "m.annotation", - event_id: eventId, - key: reactionKey, - }, - }), - } - ) - - return { success: true, eventId, key: reactionKey } - } - - const editTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string, - text: string - ) => { - const message = text.trim() - if (!eventId || !message) { - throw Object.assign( - new Error("Nachricht und Zielnachricht sind erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - msgtype: "m.text", - body: `* ${message}`, - "m.new_content": { - msgtype: "m.text", - body: message, - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: eventId, - }, - }), - } - ) - - return { - id: response.event_id, - targetEventId: eventId, - body: message, - timestamp: Date.now(), - own: true, - } - } - - const redactTenantRoomMessage = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string - ) => { - if (!eventId) { - throw Object.assign( - new Error("Zielnachricht ist erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - reason: "Nachricht in FEDEO gelöscht", - }), - } - ) - - return { success: true, eventId } - } - - const markTenantRoomRead = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - eventId: string - ) => { - if (!eventId) return { success: true, skipped: true } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { success: true, eventId } - } - - const sendTenantRoomAttachment = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - attachment: MatrixAttachmentInput - ) => { - if (!attachment.buffer?.length) { - throw Object.assign( - new Error("Attachment file is required"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const upload = await requestMatrixJson<{ content_uri: string }>( - `/_matrix/media/v3/upload?filename=${encodeURIComponent(attachment.filename)}`, - session.accessToken, - { - method: "POST", - headers: { "Content-Type": attachment.mimeType || "application/octet-stream" }, - body: attachment.buffer as any, - } - ) - const txnId = `${Date.now()}-${randomBytes(8).toString("hex")}` - const isImage = (attachment.mimeType || "").startsWith("image/") - const messageContent = { - msgtype: isImage ? "m.image" : "m.file", - body: attachment.filename, - url: upload.content_uri, - info: { - mimetype: attachment.mimeType || "application/octet-stream", - size: attachment.size, - }, - } - const response = await requestMatrixJson<{ event_id: string }>( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, - session.accessToken, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(messageContent), - } - ) - - return { - id: response.event_id, - sender: session.matrixUserId, - senderDisplayName: await getCurrentUserDisplayName(userId, tenantId), - body: attachment.filename, - attachment: { - type: isImage ? "image" : "file", - url: upload.content_uri, - fileName: attachment.filename, - mimeType: attachment.mimeType || "application/octet-stream", - size: attachment.size, - previewUrl: isImage ? matrixMediaUrl(upload.content_uri) : null, - downloadUrl: matrixMediaUrl(upload.content_uri), - isImage, - }, - timestamp: Date.now(), - own: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - } - } - - const getMediaContent = async ( - userId: string, - tenantId: number | null, - mxcUri: string - ) => { - const session = await createAccessTokenForUser(userId, tenantId) - const response = await fetch(matrixMediaUrl(mxcUri), { - headers: { - Authorization: `Bearer ${session.accessToken}`, - }, - }) - - if (!response.ok) { - throw Object.assign( - new Error(`Matrix media request failed with ${response.status}`), - { statusCode: response.status } - ) - } - - return { - buffer: Buffer.from(await response.arrayBuffer()), - contentType: response.headers.get("content-type") || "application/octet-stream", - contentLength: response.headers.get("content-length"), - } - } - - const createElementRoomSession = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - const token = await requestMatrixJson( - "/_matrix/client/v1/login/get_token", - session.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - loginToken: token.login_token, - expiresInMs: token.expires_in_ms, - homeserverUrl: homeserverUrl(), - serverName: serverName(), - } - } - - const createLiveKitRoomSession = async ( - userId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - if (!livekitKey() || !livekitSecret()) { - throw Object.assign( - new Error("LIVEKIT_KEY and LIVEKIT_SECRET are not configured"), - { statusCode: 503 } - ) - } - - const room = await provisionTenantRoom(userId, tenantId, options) - const session = await ensureCurrentUserJoinedRoom(userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - const displayName = await getCurrentUserDisplayName(userId, tenantId) - const liveKitRoomName = `fedeo-${tenantId || "global"}-${room.key}`.replace(/[^a-zA-Z0-9_-]/g, "_") - const now = Math.floor(Date.now() / 1000) - const expiresInSeconds = 60 * 60 - const video: LiveKitGrant = { - roomJoin: true, - room: liveKitRoomName, - canPublish: true, - canSubscribe: true, - } - const token = jwt.sign( - { - sub: session.matrixUserId, - name: displayName, - video, - nbf: now - 10, - exp: now + expiresInSeconds, - }, - livekitSecret(), - { - algorithm: "HS256", - issuer: livekitKey(), - } - ) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - matrixUserId: session.matrixUserId, - displayName, - liveKitUrl: livekitUrl(), - liveKitRoomName, - liveKitToken: token, - expiresInMs: expiresInSeconds * 1000, - } - } - - const listTenantCommunicationUsers = async (tenantId: number | null) => { - const tenant = await getCurrentTenant(tenantId) - const rows = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - profileActive: authProfiles.active, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenant.id) - )) - .where(eq(authTenantUsers.tenant_id, tenant.id)) - - const users = rows - .filter((row) => row.profileActive !== false) - .map((row) => ({ - userId: row.userId, - email: row.email, - displayName: row.fullName || `${row.firstName || ""} ${row.lastName || ""}`.trim() || row.email, - })) - - return await Promise.all(users.map(async (user) => ({ - ...user, - matrixUserId: await matrixUserIdForUser(user.userId, tenant.id), - }))) - } - - const inviteMatrixUserToRoom = async ( - room: { roomId: string }, - matrixUserId: string, - reason?: string - ) => { - const serviceLogin = await ensureServiceAccessToken() - - try { - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/invite`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: matrixUserId, - reason, - }), - } - ) - - return "invited" - } catch (err: any) { - if ( - err.errcode === "M_FORBIDDEN" || - err.errcode === "M_BAD_STATE" || - err.errcode === "M_UNKNOWN" - ) { - return "already_available" - } - - throw err - } - } - - const syncTenantRoomMembers = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {} - ) => { - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const users = await listTenantCommunicationUsers(tenantId) - const results = [] - - for (const user of users) { - try { - const account = await provisionCurrentUser(user.userId, tenantId) - await inviteMatrixUserToRoom( - { roomId: room.roomId }, - account.matrixUserId, - `FEDEO-Raumsynchronisation: ${room.name}` - ) - await ensureCurrentUserJoinedRoom(user.userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - results.push({ - userId: user.userId, - email: user.email, - displayName: user.displayName, - matrixUserId: account.matrixUserId, - status: "joined", - ok: true, - }) - } catch (err: any) { - results.push({ - userId: user.userId, - email: user.email, - displayName: user.displayName, - status: "failed", - ok: false, - error: err.message || "Synchronisation fehlgeschlagen", - }) - } - } - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - total: results.length, - joined: results.filter((item) => item.status === "joined").length, - invited: results.filter((item) => item.status === "invited").length, - alreadyAvailable: results.filter((item) => item.status === "already_available").length, - failed: results.filter((item) => !item.ok).length, - results, - } - } - - const inviteTenantRoomMember = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - targetUserId: string - ) => { - if (!targetUserId) { - throw Object.assign( - new Error("Benutzer ist erforderlich"), - { statusCode: 400 } - ) - } - - const users = await listTenantCommunicationUsers(tenantId) - const targetUser = users.find((user) => user.userId === targetUserId) - - if (!targetUser) { - throw Object.assign( - new Error("Benutzer gehört nicht zum aktiven Mandanten"), - { statusCode: 404 } - ) - } - - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const account = await provisionCurrentUser(targetUser.userId, tenantId) - const inviteStatus = await inviteMatrixUserToRoom( - { roomId: room.roomId }, - account.matrixUserId, - `FEDEO-Einladung: ${room.name}` - ) - await ensureCurrentUserJoinedRoom(targetUser.userId, tenantId, { - roomId: room.roomId, - alias: room.alias, - }) - - return { - roomId: room.roomId, - alias: room.alias, - key: room.key, - name: room.name, - userId: targetUser.userId, - email: targetUser.email, - displayName: targetUser.displayName, - matrixUserId: account.matrixUserId, - status: inviteStatus === "invited" ? "joined" : inviteStatus, - ok: true, - } - } - - const removeTenantRoomMember = async ( - requestingUserId: string, - tenantId: number | null, - options: MatrixTenantRoomOptions = {}, - matrixUserId: string - ) => { - if (!matrixUserId) { - throw Object.assign( - new Error("Matrix-Benutzer ist erforderlich"), - { statusCode: 400 } - ) - } - - const room = await provisionTenantRoom(requestingUserId, tenantId, options) - const session = await createAccessTokenForUser(requestingUserId, tenantId) - - if (matrixUserId === session.matrixUserId) { - throw Object.assign( - new Error("Du kannst dich nicht selbst aus dem Raum entfernen"), - { statusCode: 400 } - ) - } - - const serviceLogin = await ensureServiceAccessToken() - await requestMatrixJson( - `/_matrix/client/v3/rooms/${encodeURIComponent(room.roomId)}/kick`, - serviceLogin.accessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user_id: matrixUserId, - reason: "FEDEO-Mitgliederverwaltung", - }), - } - ) - - matrixJoinedRoomCache.delete(`${matrixUserId}:${room.roomId}`) - return { - success: true, - roomId: room.roomId, - alias: room.alias, - key: room.key, - matrixUserId, - } - } - - const getGeneralRoomMessages = (userId: string, tenantId: number | null) => - getTenantRoomMessages(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - - const getGeneralRoomMembers = (userId: string, tenantId: number | null) => - getTenantRoomMembers(userId, tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - - const sendGeneralRoomMessage = ( - userId: string, - tenantId: number | null, - text: string, - messageOptions: MatrixMessageOptions = {} - ) => - sendTenantRoomMessage( - userId, - tenantId, - { - key: "allgemein", - name: "Allgemeiner Chat", - }, - text, - messageOptions - ) - - const sendGeneralRoomAttachment = (userId: string, tenantId: number | null, attachment: MatrixAttachmentInput) => - sendTenantRoomAttachment( - userId, - tenantId, - { - key: "allgemein", - name: "Allgemeiner Chat", - }, - attachment - ) - - return { - getStatus, - matrixUserIdForUser, - getCurrentUserDisplayName, - provisionCurrentUser, - getTenantSpaceStatus, - provisionCurrentTenantSpace, - listTenantRooms, - listTenantCommunicationUsers, - getTenantRoomStatus, - provisionTenantRoom, - createAccessTokenForUser, - getTenantRoomMessages, - getTenantRoomMembers, - searchTenantRoomMessages, - syncTenantRoomEvents, - syncServiceRoomEvents, - syncServiceJoinedTenantRooms, - sendTenantRoomMessage, - sendTenantRoomReaction, - editTenantRoomMessage, - redactTenantRoomMessage, - markTenantRoomRead, - sendTenantRoomAttachment, - getMediaContent, - createElementRoomSession, - createLiveKitRoomSession, - inviteTenantRoomMember, - removeTenantRoomMember, - syncTenantRoomMembers, - getGeneralRoomMessages, - getGeneralRoomMembers, - sendGeneralRoomMessage, - sendGeneralRoomAttachment, - } -} diff --git a/backend/src/modules/system-status.service.ts b/backend/src/modules/system-status.service.ts index 0eb8641..c1f41a7 100644 --- a/backend/src/modules/system-status.service.ts +++ b/backend/src/modules/system-status.service.ts @@ -1,5 +1,4 @@ import { FastifyInstance } from "fastify" -import { matrixService } from "./matrix.service" type MetricSample = { labels: Record @@ -117,10 +116,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => { const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null const databaseCheck = await server.db.execute("SELECT NOW() as now") - const matrixStatus = await matrixService(server).getStatus().catch((err: any) => ({ - reachable: false, - error: err?.message || "Matrix-Status nicht verfügbar", - })) const minioUrl = s3EndpointUrl() return { @@ -165,7 +160,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => { url: nodeExporterMetricsUrl, error: nodeExporterError, }), - matrix: serviceState(Boolean((matrixStatus as any).reachable), matrixStatus as Record), minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { error: "S3_ENDPOINT ist nicht gesetzt", }), diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index cf8ae10..2c37636 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -30,7 +30,6 @@ import { } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport"; import { buildSystemStatus } from "../modules/system-status.service"; -import { matrixService } from "../modules/matrix.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; @@ -385,27 +384,7 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - let matrixProvisioned = false; - let matrixProvisioningError: string | null = null; - if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) { - try { - const matrix = matrixService(server); - await matrix.provisionTenantRoom(currentUser.id, result.tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }); - matrixProvisioned = true; - } catch (err: any) { - matrixProvisioningError = err?.message || String(err); - server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden"); - } - } - - return { - matrixProvisioned, - matrixProvisioningError, - }; + return { chatReady: true }; }; const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index d08df09..58faeda 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,23 +1,20 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" -import multipart from "@fastify/multipart" -import { and, desc, eq, inArray, ne } from "drizzle-orm" -import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema" -import { matrixService } from "../modules/matrix.service" -import { getMatrixPushWorkerState } from "../modules/matrix-push-worker.service" +import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" +import { + authProfiles, + authTenantUsers, + authUsers, + communicationMessages, + communicationRoomMembers, + communicationRoomReads, + communicationRooms, + notificationsItems, + projects, +} from "../../db/schema" import { NotificationService, UserDirectory } from "../modules/notification.service" -const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) => { - const rows = await server.db - .select({ email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, userId)) - .limit(1) - - return rows[0] || null -} - -type ChatRecipient = { +type ChatUser = { userId: string email?: string | null firstName?: string | null @@ -25,991 +22,429 @@ type ChatRecipient = { fullName?: string | null } +const getUserDirectory: UserDirectory = async (server, userId) => { + const [user] = await server.db + .select({ email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1) + return user || null +} + +const displayName = (user: Omit) => + user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email || "Benutzer" + +const directRoomKey = (firstUserId: string, secondUserId: string) => { + const hash = createHash("sha256") + .update([firstUserId, secondUserId].sort().join(":")) + .digest("hex") + .slice(0, 16) + return `direct_${hash}` +} + +const normalizeRoomKey = (value: string) => value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9._=-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^[._=-]+|[._=-]+$/g, "") + export default async function communicationRoutes(server: FastifyInstance) { - await server.register(multipart, { - limits: { fileSize: 25 * 1024 * 1024 }, + const notifications = new NotificationService(server, getUserDirectory) + + const requireTenant = (req: any) => { + const tenantId = Number(req.user.tenant_id) + if (!tenantId) throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 }) + return tenantId + } + + const tenantUsers = async (tenantId: number): Promise => server.db + .select({ + userId: authTenantUsers.user_id, + email: authUsers.email, + firstName: authProfiles.first_name, + lastName: authProfiles.last_name, + fullName: authProfiles.full_name, + }) + .from(authTenantUsers) + .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) + .leftJoin(authProfiles, and( + eq(authProfiles.user_id, authTenantUsers.user_id), + eq(authProfiles.tenant_id, tenantId) + )) + .where(eq(authTenantUsers.tenant_id, tenantId)) + + const addMembers = async (roomId: string, userIds: string[]) => { + const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) + if (!uniqueUserIds.length) return + await server.db.insert(communicationRoomMembers) + .values(uniqueUserIds.map((userId) => ({ roomId, userId }))) + .onConflictDoNothing() + } + + const ensureGeneralRoom = async (tenantId: number, creatorId: string) => { + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) + .limit(1) + + if (!room) { + const [created] = await server.db.insert(communicationRooms).values({ + tenantId, + key: "allgemein", + name: "Allgemeiner Chat", + topic: "Mandantenweiter Austausch", + type: "general", + createdBy: creatorId, + }).onConflictDoNothing().returning() + room = created + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) + .limit(1) + } + } + + await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId)) + return room + } + + const requireRoom = async (tenantId: number, userId: string, roomKey: string) => { + const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and( + eq(communicationRooms.tenantId, tenantId), + eq(communicationRooms.key, roomKey), + eq(communicationRooms.archived, false), + eq(communicationRoomMembers.userId, userId) + )) + .limit(1) + if (!row) throw Object.assign(new Error("Chatraum nicht gefunden oder kein Zugriff"), { statusCode: 404 }) + return row.room + } + + const roomToApi = (room: typeof communicationRooms.$inferSelect) => ({ + id: room.id, + key: room.key, + name: room.name, + topic: room.topic, + type: room.type, + entityType: room.entityType, + entityId: room.entityId, + entityUuid: room.entityUuid, + exists: true, }) - const matrix = matrixService(server) - const notifications = new NotificationService(server, getUserDirectory) - const handleMatrixError = (req: any, reply: any, err: any, fallbackMessage: string) => { - req.log.error(err) - return reply - .code(err.statusCode || 500) - .send({ error: err.message || fallbackMessage }) - } - - const roomOptionsFromRequest = (req: any) => { - const params = req.params as { roomKey?: 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, - } - } - - const projectRoomKey = (projectId: number) => `project_${projectId}` - - const directRoomKey = (firstUserId: string, secondUserId: string) => { - const hash = createHash("sha256") - .update([firstUserId, secondUserId].sort().join(":")) - .digest("hex") - .slice(0, 16) - - return `direct_${hash}` - } - - const displayUserName = (user: { fullName?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null }) => { - const name = user.fullName || [user.firstName, user.lastName].filter(Boolean).join(" ") - return name || user.email || "Benutzer" - } - - const getTenantRecipients = async (tenantId: number, senderUserId: string) => { - return await server.db - .select({ - userId: authTenantUsers.user_id, + const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { + const conditions = [eq(communicationMessages.roomId, roomId)] + if (afterId > 0) conditions.push(gt(communicationMessages.id, afterId)) + const rows = await server.db.select().from(communicationMessages) + .where(and(...conditions)) + .orderBy(afterId > 0 ? asc(communicationMessages.id) : desc(communicationMessages.id)) + .limit(Math.min(Math.max(limit, 1), 100)) + const orderedRows = afterId > 0 ? rows : rows.reverse() + const authorIds = Array.from(new Set(orderedRows.map((message) => message.authorUserId))) + const authors = authorIds.length + ? await server.db.select({ + userId: authUsers.id, email: authUsers.email, firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(and( - eq(authTenantUsers.tenant_id, tenantId), - ne(authTenantUsers.user_id, senderUserId) - )) + }).from(authUsers) + .leftJoin(authProfiles, and(eq(authProfiles.user_id, authUsers.id), eq(authProfiles.tenant_id, tenantId))) + .where(inArray(authUsers.id, authorIds)) + : [] + const authorById = new Map(authors.map((author) => [author.userId, author])) + return orderedRows.map((message) => ({ + id: message.id, + body: message.body, + sender: message.authorUserId, + senderDisplayName: displayName(authorById.get(message.authorUserId) || {}), + timestamp: message.createdAt.getTime(), + own: message.authorUserId === currentUserId, + })) } - const getTenantUser = async (tenantId: number, userId: string): Promise => { - const [user] = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(and( - eq(authTenantUsers.tenant_id, tenantId), - eq(authTenantUsers.user_id, userId) - )) - .limit(1) - - return user || null - } - - const getSenderName = async (tenantId: number, senderUserId: string) => { - const [sender] = await server.db - .select({ - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authUsers) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authUsers.id), - eq(authProfiles.tenant_id, tenantId) - )) - .where(eq(authUsers.id, senderUserId)) - .limit(1) - - return sender ? displayUserName(sender) : "FEDEO" - } - - const mentionAliasesForUser = (user: ChatRecipient) => { - const name = displayUserName(user) - return Array.from(new Set([ - name, - user.fullName, - [user.firstName, user.lastName].filter(Boolean).join(" "), - user.firstName, - user.email, - ].filter(Boolean).map((value) => String(value).toLowerCase()))) - } - - const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => { - const normalizedText = text.toLowerCase() - - return recipients - .filter((recipient) => mentionAliasesForUser(recipient).some((alias) => - normalizedText.includes(`@${alias}`) - )) - .map((recipient) => recipient.userId) - } - - const chatMessageRecipients = async ( + const notifyMessageRecipients = async ( tenantId: number, - senderUserId: string, - room: any, - text: string + room: typeof communicationRooms.$inferSelect, + senderId: string, + message: { id: number; body: string } ) => { - const recipients = await getTenantRecipients(tenantId, senderUserId) - const mentioned = new Set(mentionedRecipientIds(text, recipients)) - const directRecipients = new Set() - - if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) { - directRecipients.add(room.entityUuid) - } else if (room?.type === "direct" && room.key) { - recipients - .filter((recipient) => directRoomKey(senderUserId, recipient.userId) === room.key) - .forEach((recipient) => directRecipients.add(recipient.userId)) - } - - return recipients - .filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId)) - .map((recipient) => ({ - ...recipient, - mentioned: mentioned.has(recipient.userId), - direct: directRecipients.has(recipient.userId), - })) - } - - const notifyUsersAboutChatMessage = async (req: any, room: any, message: any, text: string) => { - if (!req.user.tenant_id) return - try { - const recipients = await chatMessageRecipients(req.user.tenant_id, req.user.user_id, room, text) + const users = await tenantUsers(tenantId) + const sender = users.find((user) => user.userId === senderId) + const memberRows = await server.db.select({ userId: communicationRoomMembers.userId }) + .from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id)) + const memberIds = new Set(memberRows.map((member) => member.userId)) + const normalizedText = message.body.toLowerCase() + const recipients = users.filter((user) => { + if (user.userId === senderId || !memberIds.has(user.userId)) return false + if (room.type === "direct") return true + const aliases = [displayName(user), user.fullName, user.firstName, user.email] + .filter(Boolean).map((value) => String(value).toLowerCase()) + return aliases.some((alias) => normalizedText.includes(`@${alias}`)) + }) if (!recipients.length) return - - const senderName = await getSenderName(req.user.tenant_id, req.user.user_id) - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - for (const recipient of recipients) { - await notifications.trigger({ - tenantId: req.user.tenant_id, - userId: recipient.userId, - eventType: "communication.message.new", - title: recipient.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - mentioned: recipient.mentioned, - direct: recipient.direct, - }, - channels: ["inapp", "push"], - }) - } - } catch (err) { - req.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden") - } - } - - const hasChatNotificationForMessage = async (tenantId: number, userId: string, messageId: string) => { - const rows = await server.db - .select({ - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new") - )) - .orderBy(desc(notificationsItems.createdAt)) - .limit(200) - - return rows.some((row) => (row.payload as any)?.messageId === messageId) - } - - const notifyCurrentUserAboutIncomingMatrixMessages = async (req: any, room: any, messages: any[]) => { - if (!req.user.tenant_id || !messages.length) return - - try { - const currentUser = await getTenantUser(req.user.tenant_id, req.user.user_id) - if (!currentUser) return - - for (const message of messages) { - if (message.own || !message.id) continue - - const text = message.body || message.attachment?.fileName || "Neue Nachricht" - const mentioned = mentionedRecipientIds(text, [currentUser]).includes(currentUser.userId) - const direct = room?.type === "direct" - - if (!direct && !mentioned) continue - if (await hasChatNotificationForMessage(req.user.tenant_id, req.user.user_id, message.id)) continue - - const senderName = message.senderDisplayName || message.sender || "Matrix" - const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text - - await notifications.trigger({ - tenantId: req.user.tenant_id, - userId: req.user.user_id, - eventType: "communication.message.new", - title: mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`, - message: preview, - payload: { - link: `/communication/chat?room=${encodeURIComponent(room.key)}`, - roomKey: room.key, - roomName: room.name, - roomType: room.type, - messageId: message.id, - matrixSender: message.sender, - mentioned, - direct, - }, - channels: ["inapp", "push"], - }) - } - } catch (err) { - req.log.error({ err }, "Eingehende Matrix-Benachrichtigung konnte nicht ausgelöst werden") - } - } - - const unreadChatNotifications = async (tenantId: number, userId: string) => { - return await server.db - .select({ - id: notificationsItems.id, - payload: notificationsItems.payload, - }) - .from(notificationsItems) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - eq(notificationsItems.eventType, "communication.message.new"), - eq(notificationsItems.channel, "inapp"), - ne(notificationsItems.status, "read") - )) - } - - const markRoomNotificationsRead = async (tenantId: number, userId: string, roomKey: string) => { - const rows = await unreadChatNotifications(tenantId, userId) - const ids = rows - .filter((row) => (row.payload as any)?.roomKey === roomKey) - .map((row) => row.id) - - if (!ids.length) return { read: 0 } - - await server.db - .update(notificationsItems) - .set({ readAt: new Date(), status: "read" }) - .where(and( - eq(notificationsItems.tenantId, tenantId), - eq(notificationsItems.userId, userId), - inArray(notificationsItems.id, ids) - )) - - return { read: ids.length } - } - - const uploadedAttachmentFromRequest = async (req: any) => { - const data = await req.file() - if (!data?.file) { - throw Object.assign( - new Error("Keine Datei hochgeladen"), - { statusCode: 400 } - ) - } - - const buffer = await data.toBuffer() - return { - buffer, - filename: data.filename || "Anhang", - mimeType: data.mimetype || "application/octet-stream", - size: buffer.length, - } - } - - const callModeFromRequest = (req: any): "audio" | "video" => { - const body = (req.body || {}) as { mode?: string } - return body.mode === "audio" ? "audio" : "video" - } - - const notifyTenantUsersAboutCall = async (req: any, room: { key?: string; name?: string }, mode: "audio" | "video") => { - if (!req.user.tenant_id) return - - try { - const recipientRows = await server.db - .select({ userId: authTenantUsers.user_id }) - .from(authTenantUsers) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - ne(authTenantUsers.user_id, req.user.user_id) - )) - - const userIds = recipientRows.map((row) => row.userId) - if (!userIds.length) return - + const senderName = sender ? displayName(sender) : "FEDEO" await notifications.trigger({ - tenantId: req.user.tenant_id, - userIds, - eventType: "communication.call.started", - title: mode === "audio" ? "Audioanruf gestartet" : "Videokonferenz gestartet", - message: `${room.name || room.key || "Ein Chatraum"} hat eine laufende Besprechung.`, + tenantId, + userIds: recipients.map((recipient) => recipient.userId), + eventType: "communication.message.new", + title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`, + message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body, payload: { - link: "/communication/chat", + link: `/communication/chat?room=${encodeURIComponent(room.key)}`, roomKey: room.key, roomName: room.name, - mode, + roomType: room.type, + messageId: message.id, + mentioned: room.type !== "direct", + direct: room.type === "direct", }, channels: ["inapp", "push"], }) } catch (err) { - req.log.error({ err }, "Call-Benachrichtigung konnte nicht ausgelöst werden") + server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden") } } - server.get("/communication/matrix/status", async () => { - return matrix.getStatus() + const unreadNotifications = async (tenantId: number, userId: string) => server.db + .select({ id: notificationsItems.id, payload: notificationsItems.payload }) + .from(notificationsItems) + .where(and( + eq(notificationsItems.tenantId, tenantId), + eq(notificationsItems.userId, userId), + eq(notificationsItems.eventType, "communication.message.new"), + eq(notificationsItems.channel, "inapp"), + ne(notificationsItems.status, "read") + )) + + server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" })) + + server.get("/communication/chat/users", async (req: any) => { + const tenantId = requireTenant(req) + return { users: (await tenantUsers(tenantId)).map((user) => ({ + userId: user.userId, + email: user.email, + displayName: displayName(user), + })) } }) - server.get("/communication/matrix/me", async (req) => { - const userId = req.user.user_id - - return { - matrixUserId: await matrix.matrixUserIdForUser(userId, req.user.tenant_id), - displayName: await matrix.getCurrentUserDisplayName(userId, req.user.tenant_id), - } + server.get("/communication/chat/rooms", async (req: any) => { + const tenantId = requireTenant(req) + await ensureGeneralRoom(tenantId, req.user.user_id) + const rows = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and( + eq(communicationRooms.tenantId, tenantId), + eq(communicationRooms.archived, false), + eq(communicationRoomMembers.userId, req.user.user_id) + )).orderBy(asc(communicationRooms.name)) + return { rooms: rows.map(({ room }) => roomToApi(room)) } }) - server.post("/communication/matrix/me/provision", async (req, reply) => { - try { - return await matrix.provisionCurrentUser(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix provisioning failed") - } + server.post("/communication/chat/rooms", async (req: any, reply) => { + const tenantId = requireTenant(req) + const body = (req.body || {}) as { key?: string; name?: string; topic?: string } + const name = body.name?.trim() + const key = normalizeRoomKey(body.key?.trim() || name || "") + if (!name || !key) return reply.code(400).send({ error: "Name und gültiger Raumschlüssel sind erforderlich" }) + const [created] = await server.db.insert(communicationRooms).values({ + tenantId, key, name, topic: body.topic?.trim() || null, type: "room", createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + const room = created || (await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1))[0] + await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId)) + return { ...roomToApi(room), alreadyExisted: !created } }) - server.get("/communication/matrix/tenant-space", async (req, reply) => { - try { - return await matrix.getTenantSpaceStatus(req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix tenant space status failed") - } - }) - - server.post("/communication/matrix/tenant-space/provision", async (req, reply) => { - try { - return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix tenant space provisioning failed") - } - }) - - server.get("/communication/matrix/rooms", async (req, reply) => { - try { - return await matrix.listTenantRooms(req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix rooms failed") - } - }) - - server.get("/communication/matrix/unread", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - - const rows = await unreadChatNotifications(req.user.tenant_id, req.user.user_id) - const rooms = rows.reduce((acc: Record, row) => { - const payload = row.payload as any - const roomKey = payload?.roomKey - if (!roomKey) return acc - - acc[roomKey] = acc[roomKey] || { count: 0, mentions: 0 } - acc[roomKey].count += 1 - - if (payload.mentioned) { - acc[roomKey].mentions += 1 - } - - return acc - }, {}) - - return { rooms } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix unread state failed") - } - }) - - server.get("/communication/matrix/push-worker", async () => { - return getMatrixPushWorkerState() - }) - - server.get("/communication/matrix/media", async (req, reply) => { - try { - const query = req.query as { uri?: string; name?: string } - if (!query.uri) return reply.code(400).send({ error: "Matrix-Media-URI fehlt" }) - - const media = await matrix.getMediaContent(req.user.user_id, req.user.tenant_id, query.uri) - reply.header("Content-Type", media.contentType) - if (media.contentLength) reply.header("Content-Length", media.contentLength) - if (query.name) { - reply.header("Content-Disposition", `inline; filename="${query.name.replace(/"/g, "")}"`) - } - return reply.send(media.buffer) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix media failed") - } - }) - - server.get("/communication/matrix/project-rooms", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - - const [roomsRes, projectRows] = await Promise.all([ - matrix.listTenantRooms(req.user.tenant_id), - server.db - .select({ - id: projects.id, - name: projects.name, - projectNumber: projects.projectNumber, - profiles: projects.profiles, - }) - .from(projects) - .where(and( - eq(projects.tenant, req.user.tenant_id), - eq(projects.archived, false) - )) - ]) - const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room])) - - return { - rooms: projectRows.map((project) => { - const key = projectRoomKey(project.id) - const existing = roomsByKey.get(key) as any - return { - ...(existing || {}), - key, - name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, - topic: `Projektkommunikation zu ${project.name}`, - type: "project", - entityType: "project", - entityId: project.id, - exists: Boolean(existing?.exists), - projectId: project.id, - projectNumber: project.projectNumber, - } - }) - } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix project rooms failed") - } - }) - - server.post("/communication/matrix/project-rooms/:projectId/provision", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { projectId: string } - const projectId = Number(params.projectId) - const [project] = await server.db - .select() - .from(projects) - .where(and( - eq(projects.tenant, req.user.tenant_id), - eq(projects.id, projectId) - )) - .limit(1) - - if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" }) - - const profileIds = (project.profiles || []) as string[] - const profileRows = profileIds.length - ? await server.db - .select({ userId: authProfiles.user_id }) - .from(authProfiles) - .where(and( - eq(authProfiles.tenant_id, req.user.tenant_id), - inArray(authProfiles.id, profileIds) - )) - : [] - const inviteUserIds = profileRows.map((profile) => profile.userId).filter(Boolean) as string[] - - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: projectRoomKey(project.id), + server.get("/communication/chat/project-rooms", async (req: any) => { + const tenantId = requireTenant(req) + const [roomRows, projectRows] = await Promise.all([ + server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "project"), eq(communicationRoomMembers.userId, req.user.user_id))), + server.db.select().from(projects).where(and(eq(projects.tenant, tenantId), eq(projects.archived, false))), + ]) + const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room])) + return { rooms: projectRows.map((project) => { + const room = roomsByProject.get(project.id) + return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : { + key: `project_${project.id}`, name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, topic: `Projektkommunikation zu ${project.name}`, - type: "project", - entityType: "project", - entityId: project.id, - inviteUserIds, - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix project room provisioning failed") - } - }) - - server.get("/communication/matrix/direct-rooms", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const [roomsRes, userRows] = await Promise.all([ - matrix.listTenantRooms(req.user.tenant_id), - server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, req.user.tenant_id) - )) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - ne(authTenantUsers.user_id, req.user.user_id) - )) - ]) - const roomsByKey = new Map((roomsRes.rooms || []).map((room: any) => [room.key, room])) - - return { - rooms: userRows.map((user) => { - const key = directRoomKey(req.user.user_id, user.userId) - const existing = roomsByKey.get(key) as any - const name = displayUserName(user) - - return { - ...(existing || {}), - key, - name, - topic: `Direktnachricht mit ${name}`, - type: "direct", - entityType: "user", - entityUuid: user.userId, - exists: Boolean(existing?.exists), - userId: user.userId, - email: user.email, - } - }) + type: "project", entityType: "project", entityId: project.id, + projectId: project.id, projectNumber: project.projectNumber, exists: false, } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix direct rooms failed") - } + }) } }) - server.post("/communication/matrix/direct-rooms/:userId/provision", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { userId: string } - const [target] = await server.db - .select({ - userId: authTenantUsers.user_id, - email: authUsers.email, - firstName: authProfiles.first_name, - lastName: authProfiles.last_name, - fullName: authProfiles.full_name, - }) - .from(authTenantUsers) - .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) - .leftJoin(authProfiles, and( - eq(authProfiles.user_id, authTenantUsers.user_id), - eq(authProfiles.tenant_id, req.user.tenant_id) - )) - .where(and( - eq(authTenantUsers.tenant_id, req.user.tenant_id), - eq(authTenantUsers.user_id, params.userId) - )) - .limit(1) - - if (!target || target.userId === req.user.user_id) { - return reply.code(404).send({ error: "Benutzer nicht gefunden" }) + server.post("/communication/chat/project-rooms/:projectId/provision", async (req: any, reply) => { + const tenantId = requireTenant(req) + const projectId = Number(req.params.projectId) + const [project] = await server.db.select().from(projects) + .where(and(eq(projects.tenant, tenantId), eq(projects.id, projectId))).limit(1) + if (!project) return reply.code(404).send({ error: "Projekt nicht gefunden" }) + const key = `project_${project.id}` + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) + if (!room) { + [room] = await server.db.insert(communicationRooms).values({ + tenantId, key, + name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, + topic: `Projektkommunikation zu ${project.name}`, + type: "project", entityType: "project", entityId: project.id, createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) } - - const targetName = displayUserName(target) - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: directRoomKey(req.user.user_id, target.userId), - name: targetName, - topic: `Direktnachricht mit ${targetName}`, - type: "direct", - entityType: "user", - entityUuid: target.userId, - inviteUserIds: [target.userId], - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix direct room provisioning failed") } + const profileIds = (project.profiles || []) as string[] + const memberProfiles = profileIds.length + ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) + .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds))) + : [] + const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] + await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) + return roomToApi(room) }) - server.post("/communication/matrix/rooms", async (req, reply) => { - try { - return await matrix.provisionTenantRoom( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.get("/communication/matrix/rooms/general", async (req, reply) => { - try { - return await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room status failed") - } - }) - - server.post("/communication/matrix/rooms/general/provision", async (req, reply) => { - try { - return await matrix.provisionTenantRoom(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.get("/communication/matrix/rooms/general/messages", async (req, reply) => { - try { - return await matrix.getGeneralRoomMessages(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix messages failed") - } - }) - - server.get("/communication/matrix/rooms/general/members", async (req, reply) => { - try { - return await matrix.getGeneralRoomMembers(req.user.user_id, req.user.tenant_id) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix members failed") - } - }) - - server.get("/communication/matrix/users", async (req, reply) => { - try { - const users = await matrix.listTenantCommunicationUsers(req.user.tenant_id) - return { users } - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix users failed") - } - }) - - server.post("/communication/matrix/rooms/general/session", async (req, reply) => { - try { - return await matrix.createElementRoomSession(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix session failed") - } - }) - - server.post("/communication/matrix/rooms/general/call-session", async (req, reply) => { - try { - const room = { - key: "allgemein", - name: "Allgemeiner Chat", + server.get("/communication/chat/direct-rooms", async (req: any) => { + const tenantId = requireTenant(req) + const users = (await tenantUsers(tenantId)).filter((user) => user.userId !== req.user.user_id) + const existingRows = await server.db.select({ room: communicationRooms }).from(communicationRooms) + .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id))) + const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room])) + return { rooms: users.map((user) => { + const key = directRoomKey(req.user.user_id, user.userId) + const room = roomsByKey.get(key) + return room ? { + ...roomToApi(room), + name: displayName(user), + topic: `Direktnachricht mit ${displayName(user)}`, + entityUuid: user.userId, + userId: user.userId, + email: user.email, + } : { + key, name: displayName(user), topic: `Direktnachricht mit ${displayName(user)}`, + type: "direct", entityType: "user", entityUuid: user.userId, + userId: user.userId, email: user.email, exists: false, } - const session = await matrix.createLiveKitRoomSession(req.user.user_id, req.user.tenant_id, room) - await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req)) - return session - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix call session failed") - } + }) } }) - server.post("/communication/matrix/rooms/general/members/sync", async (req, reply) => { - try { - return await matrix.syncTenantRoomMembers(req.user.user_id, req.user.tenant_id, { - key: "allgemein", - name: "Allgemeiner Chat", - }) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member sync failed") - } - }) - - server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { - try { - const body = req.body as { text?: string; replyToEventId?: string } - const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "", { - replyToEventId: body.replyToEventId, - }) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - await notifyUsersAboutChatMessage(req, room, message, body.text || "") - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message send failed") - } - }) - - server.post("/communication/matrix/rooms/general/attachments", async (req, reply) => { - try { - const attachment = await uploadedAttachmentFromRequest(req) - const message = await matrix.sendGeneralRoomAttachment(req.user.user_id, req.user.tenant_id, attachment) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat") - await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix attachment send failed") - } - }) - - 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) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room status failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/provision", async (req, reply) => { - try { - return await matrix.provisionTenantRoom( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room provisioning failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/read", async (req, reply) => { - try { - if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) - const params = req.params as { roomKey: string } - const body = (req.body || {}) as { eventId?: string } - const result = await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) - if (body.eventId) { - await matrix.markTenantRoomRead(req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.eventId) + server.post("/communication/chat/direct-rooms/:userId/provision", async (req: any, reply) => { + const tenantId = requireTenant(req) + const targetId = String(req.params.userId) + const target = (await tenantUsers(tenantId)).find((user) => user.userId === targetId) + if (!target || targetId === req.user.user_id) return reply.code(404).send({ error: "Benutzer nicht gefunden" }) + const key = directRoomKey(req.user.user_id, targetId) + let [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) + if (!room) { + [room] = await server.db.insert(communicationRooms).values({ + tenantId, key, name: displayName(target), topic: `Direktnachricht mit ${displayName(target)}`, + type: "direct", entityType: "user", entityUuid: targetId, createdBy: req.user.user_id, + }).onConflictDoNothing().returning() + if (!room) { + [room] = await server.db.select().from(communicationRooms) + .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, key))).limit(1) } - return result - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix room read state failed") + } + await addMembers(room.id, [req.user.user_id, targetId]) + return { + ...roomToApi(room), + name: displayName(target), + topic: `Direktnachricht mit ${displayName(target)}`, + entityUuid: targetId, + userId: targetId, + email: target.email, } }) - server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { - try { - return await matrix.getTenantRoomMessages( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix messages failed") + server.get("/communication/chat/unread", async (req: any) => { + const tenantId = requireTenant(req) + const memberships = await server.db.select({ + roomId: communicationRoomMembers.roomId, + roomKey: communicationRooms.key, + lastReadMessageId: communicationRoomReads.lastReadMessageId, + }).from(communicationRoomMembers) + .innerJoin(communicationRooms, eq(communicationRooms.id, communicationRoomMembers.roomId)) + .leftJoin(communicationRoomReads, and(eq(communicationRoomReads.roomId, communicationRoomMembers.roomId), eq(communicationRoomReads.userId, req.user.user_id))) + .where(and(eq(communicationRoomMembers.userId, req.user.user_id), eq(communicationRooms.tenantId, tenantId))) + const rooms: Record = {} + for (const membership of memberships) { + const [result] = await server.db.select({ count: sql`count(*)::int` }).from(communicationMessages) + .where(and(eq(communicationMessages.roomId, membership.roomId), gt(communicationMessages.id, membership.lastReadMessageId || 0), ne(communicationMessages.authorUserId, req.user.user_id))) + rooms[membership.roomKey] = { count: result?.count || 0, mentions: 0 } } + return { rooms } }) - server.get("/communication/matrix/rooms/:roomKey/search", async (req, reply) => { - try { - const query = req.query as { q?: string } - return await matrix.searchTenantRoomMessages( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - query.q || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix search failed") - } + server.get("/communication/chat/rooms/:roomKey/messages", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + return { ...roomToApi(room), messages: await messagesForRoom(room.id, req.user.user_id, tenantId) } }) - server.get("/communication/matrix/rooms/:roomKey/sync", async (req, reply) => { - try { - const query = req.query as { since?: string; initial?: string } - const result = await matrix.syncTenantRoomEvents( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - query.since, - query.initial === "1" - ) - if (query.since && query.initial !== "1" && result.messages?.length) { - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, result.key, result.name) - await notifyCurrentUserAboutIncomingMatrixMessages(req, room, result.messages) - } - - return result - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix sync failed") - } + server.get("/communication/chat/rooms/:roomKey/sync", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const afterId = Math.max(Number(req.query?.afterId || 0), 0) + const messages = await messagesForRoom(room.id, req.user.user_id, tenantId, afterId, 100) + return { ...roomToApi(room), messages, nextId: messages[messages.length - 1]?.id || afterId } }) - server.get("/communication/matrix/rooms/:roomKey/members", async (req, reply) => { - try { - return await matrix.getTenantRoomMembers( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix members failed") - } + server.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const body = String(req.body?.text || "").trim() + if (!body) return reply.code(400).send({ error: "Nachricht darf nicht leer sein" }) + if (body.length > 10_000) return reply.code(400).send({ error: "Nachricht ist zu lang" }) + const [created] = await server.db.insert(communicationMessages).values({ + tenantId, roomId: room.id, authorUserId: req.user.user_id, body, + }).returning() + await notifyMessageRecipients(tenantId, room, req.user.user_id, { id: created.id, body }) + return { id: created.id, body: created.body, sender: created.authorUserId, senderDisplayName: "Du", timestamp: created.createdAt.getTime(), own: true } }) - server.post("/communication/matrix/rooms/:roomKey/members/invite", async (req, reply) => { - try { - const body = req.body as { userId?: string } - return await matrix.inviteTenantRoomMember( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - body.userId || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member invite failed") - } + server.post("/communication/chat/rooms/:roomKey/read", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const requestedId = Math.max(Number(req.body?.messageId || 0), 0) + const [latest] = await server.db.select({ id: communicationMessages.id }).from(communicationMessages) + .where(eq(communicationMessages.roomId, room.id)).orderBy(desc(communicationMessages.id)).limit(1) + const latestId = latest?.id || 0 + const messageId = requestedId ? Math.min(requestedId, latestId) : latestId + await server.db.insert(communicationRoomReads).values({ + roomId: room.id, userId: req.user.user_id, lastReadMessageId: messageId || null, readAt: new Date(), + }).onConflictDoUpdate({ + target: [communicationRoomReads.roomId, communicationRoomReads.userId], + set: { lastReadMessageId: messageId || null, readAt: new Date() }, + }) + const notificationRows = await unreadNotifications(tenantId, req.user.user_id) + const ids = notificationRows.filter((item) => (item.payload as any)?.roomKey === room.key).map((item) => item.id) + if (ids.length) await server.db.update(notificationsItems).set({ readAt: new Date(), status: "read" }).where(inArray(notificationsItems.id, ids)) + return { read: true, messageId } }) - server.delete("/communication/matrix/rooms/:roomKey/members/:matrixUserId", async (req, reply) => { - try { - const params = req.params as { matrixUserId: string } - return await matrix.removeTenantRoomMember( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.matrixUserId - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member remove failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/session", async (req, reply) => { - try { - return await matrix.createElementRoomSession( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix session failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/call-session", async (req, reply) => { - try { - const room = roomOptionsFromRequest(req) - const session = await matrix.createLiveKitRoomSession( - req.user.user_id, - req.user.tenant_id, - room - ) - await notifyTenantUsersAboutCall(req, room, callModeFromRequest(req)) - return session - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix call session failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/members/sync", async (req, reply) => { - try { - return await matrix.syncTenantRoomMembers( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req) - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix member sync failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { - try { - const body = req.body as { text?: string; replyToEventId?: string } - const message = await matrix.sendTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - body.text || "", - { - replyToEventId: body.replyToEventId, - } - ) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) - await notifyUsersAboutChatMessage(req, room, message, body.text || "") - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message send failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/messages/:eventId/reactions", async (req, reply) => { - try { - const params = req.params as { eventId: string } - const body = req.body as { key?: string } - return await matrix.sendTenantRoomReaction( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId, - body.key || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix reaction send failed") - } - }) - - server.put("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { - try { - const params = req.params as { eventId: string } - const body = req.body as { text?: string } - return await matrix.editTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId, - body.text || "" - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message edit failed") - } - }) - - server.delete("/communication/matrix/rooms/:roomKey/messages/:eventId", async (req, reply) => { - try { - const params = req.params as { eventId: string } - return await matrix.redactTenantRoomMessage( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - params.eventId - ) - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix message delete failed") - } - }) - - server.post("/communication/matrix/rooms/:roomKey/attachments", async (req, reply) => { - try { - const attachment = await uploadedAttachmentFromRequest(req) - const message = await matrix.sendTenantRoomAttachment( - req.user.user_id, - req.user.tenant_id, - roomOptionsFromRequest(req), - attachment - ) - const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key) - await notifyUsersAboutChatMessage(req, room, message, `Anhang: ${attachment.filename}`) - return message - } catch (err: any) { - return handleMatrixError(req, reply, err, "Matrix attachment send failed") - } + server.get("/communication/chat/rooms/:roomKey/members", async (req: any) => { + const tenantId = requireTenant(req) + const room = await requireRoom(tenantId, req.user.user_id, req.params.roomKey) + const members = await server.db.select({ + userId: authUsers.id, email: authUsers.email, + firstName: authProfiles.first_name, lastName: authProfiles.last_name, fullName: authProfiles.full_name, + }).from(communicationRoomMembers) + .innerJoin(authUsers, eq(authUsers.id, communicationRoomMembers.userId)) + .leftJoin(authProfiles, and(eq(authProfiles.user_id, authUsers.id), eq(authProfiles.tenant_id, tenantId))) + .where(eq(communicationRoomMembers.roomId, room.id)) + return { members: members.map((member) => ({ + userId: member.userId, displayName: displayName(member), email: member.email, own: member.userId === req.user.user_id, + })) } }) } diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index aea020c..c523651 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -38,15 +38,6 @@ export let secrets = { DOKUBOX_IMAP_PASSWORD: string OPENAI_API_KEY: string STIRLING_API_KEY: string - MATRIX_HOMESERVER_URL?: string - MATRIX_SERVER_NAME?: string - MATRIX_RTC_HOST?: string - MATRIX_RTC_JWT_URL?: string - MATRIX_LIVEKIT_URL?: string - MATRIX_REGISTRATION_SHARED_SECRET?: string - MATRIX_SERVICE_USER_LOCALPART?: string - LIVEKIT_KEY?: string - LIVEKIT_SECRET?: string WEB_PUSH_PUBLIC_KEY?: string WEB_PUSH_PRIVATE_KEY?: string WEB_PUSH_SUBJECT?: string @@ -88,15 +79,6 @@ const secretKeys = [ "DOKUBOX_IMAP_PASSWORD", "OPENAI_API_KEY", "STIRLING_API_KEY", - "MATRIX_HOMESERVER_URL", - "MATRIX_SERVER_NAME", - "MATRIX_RTC_HOST", - "MATRIX_RTC_JWT_URL", - "MATRIX_LIVEKIT_URL", - "MATRIX_REGISTRATION_SHARED_SECRET", - "MATRIX_SERVICE_USER_LOCALPART", - "LIVEKIT_KEY", - "LIVEKIT_SECRET", "WEB_PUSH_PUBLIC_KEY", "WEB_PUSH_PRIVATE_KEY", "WEB_PUSH_SUBJECT", diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index 347b6c7..54bd8a0 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -86,47 +86,6 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"]) const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` -const matrixServerName = () => - process.env.MATRIX_SERVER_NAME || - secrets.MATRIX_SERVER_NAME || - process.env.DOMAIN || - "localhost" - -const normalizeMatrixLocalpartSeed = (value: string) => { - const normalized = value - .toLowerCase() - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/ä/g, "a") - .replace(/ö/g, "o") - .replace(/ü/g, "u") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9._=-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^[._=-]+|[._=-]+$/g, "") - - return normalized || "user" -} - -const normalizeMatrixAliasSeed = (value: string) => - normalizeMatrixLocalpartSeed(value) - .replace(/[.=]/g, "_") - .replace(/_+/g, "_") - -const tenantRoomAliasLocalpart = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string -) => { - const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) - const roomSeed = normalizeMatrixAliasSeed(roomKey) - return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` -} - -const tenantRoomAlias = ( - tenant: { id: number, short?: string | null, name?: string | null }, - roomKey: string -) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}` - const tableColumns = async (client: any) => { const result = await client.query(` select table_name, column_name, data_type, is_generated @@ -343,6 +302,12 @@ export const buildTenantFullExport = async ( addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds])) } + const communicationRoomIds = collectIds(tables.communication_rooms || [], "id") + if (communicationRoomIds.length) { + addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds])) + addRows(tables, "communication_room_reads", await loadRows(client, "communication_room_reads", "room_id = any($1::uuid[])", [communicationRoomIds])) + } + if (tables.entitybankaccounts?.length) { tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts) } @@ -636,73 +601,6 @@ const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => } } -const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => { - const rows = exportData.tables.communication_rooms || [] - if (!rows.length) return - - const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ - Number(tenant.id), - { - id: Number(tenant.id), - name: tenant.name, - short: tenant.short, - }, - ])) - - for (const row of rows) { - const tenantId = Number(row.tenant_id) - const tenant = tenantById.get(tenantId) - - row.matrix_room_id = null - row.parent_space_room_id = null - - if (tenant && row.key) { - row.matrix_alias = tenantRoomAlias(tenant, String(row.key)) - } else { - row.matrix_alias = null - } - } -} - -const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => { - const rows = exportData.tables.communication_rooms || [] - if (!rows.length) return 0 - - const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ - Number(tenant.id), - { - id: Number(tenant.id), - name: tenant.name, - short: tenant.short, - }, - ])) - let cleaned = 0 - - for (const row of rows) { - const tenantId = Number(row.tenant_id) - const key = String(row.key || "") - const tenant = tenantById.get(tenantId) - if (!tenantId || !key || !tenant) continue - - const alias = tenantRoomAlias(tenant, key) - const result = await client.query( - ` - update communication_rooms - set matrix_room_id = null, - parent_space_room_id = null, - matrix_alias = $3, - updated_at = now() - where tenant_id = $1 and key = $2 - `, - [tenantId, key, alias] - ) - - cleaned += result.rowCount || 0 - } - - return cleaned -} - const prepareColumnValue = (value: any, isJsonColumn: boolean) => { if (!isJsonColumn || value === null || typeof value === "undefined") return value if (typeof value === "string") return value @@ -853,7 +751,6 @@ export const importTenantFullExport = async ( const exportData = rawExportData encryptEntityBankAccountRowsForImport(exportData) - prepareCommunicationRoomsForImport(exportData) const client = await pool.connect() const importOrder = [ "tenants", @@ -955,13 +852,6 @@ export const importTenantFullExport = async ( await reportProgress(`${table} importiert`) } - const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData) - if (cleanedCommunicationRooms) { - importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms }) - } - progressDone += 1 - await reportProgress("Kommunikationsräume bereinigt") - await refreshSequences(client, columnsByTable) progressDone = progressTotal await reportProgress("Import abgeschlossen") diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 474e08c..821ff0f 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -93,8 +93,6 @@ services: condition: service_healthy createbuckets: condition: service_completed_successfully - matrix-synapse: - condition: service_healthy environment: NODE_ENV: production FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true} @@ -144,16 +142,6 @@ services: FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer} FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO} FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO} - FEDEO_BOOTSTRAP_MATRIX: ${FEDEO_BOOTSTRAP_MATRIX:-true} - MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://matrix-synapse:8008} - MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - MATRIX_RTC_HOST: ${MATRIX_RTC_HOST:-${DOMAIN}} - MATRIX_RTC_JWT_URL: ${MATRIX_RTC_JWT_URL:-} - MATRIX_LIVEKIT_URL: ${MATRIX_LIVEKIT_URL:-} - MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret} - MATRIX_SERVICE_USER_LOCALPART: ${MATRIX_SERVICE_USER_LOCALPART:-fedeo_service} - LIVEKIT_KEY: ${LIVEKIT_KEY:-fedeo-livekit} - LIVEKIT_SECRET: ${LIVEKIT_SECRET:-change-this-livekit-secret-please-replace} NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100} labels: - traefik.enable=true @@ -195,7 +183,6 @@ services: NODE_ENV: production NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE} - NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-} labels: - traefik.enable=true - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) @@ -207,306 +194,6 @@ services: networks: - web - matrix-db: - image: postgres:16-alpine - container_name: fedeo-matrix-db - restart: unless-stopped - environment: - POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse} - POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse} - POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password} - POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C - volumes: - - ./matrix/postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${MATRIX_POSTGRES_USER:-synapse} -d ${MATRIX_POSTGRES_DB:-synapse}"] - interval: 10s - timeout: 5s - retries: 10 - networks: - - internal - - matrix-redis: - image: redis:7-alpine - container_name: fedeo-matrix-redis - restart: unless-stopped - networks: - - internal - - matrix-synapse: - image: ghcr.io/element-hq/synapse:latest - container_name: fedeo-matrix-synapse - restart: unless-stopped - depends_on: - matrix-db: - condition: service_healthy - matrix-redis: - condition: service_started - environment: - DOMAIN: ${DOMAIN} - MATRIX_POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse} - MATRIX_POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse} - MATRIX_POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD:-change-this-matrix-db-password} - MATRIX_REGISTRATION_SHARED_SECRET: ${MATRIX_REGISTRATION_SHARED_SECRET:-change-this-matrix-registration-secret} - MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - MATRIX_TURN_SHARED_SECRET: ${MATRIX_TURN_SHARED_SECRET:-change-this-turn-secret} - SYNAPSE_CONFIG_PATH: /data/homeserver.yaml - SYNAPSE_REPORT_STATS: "no" - SYNAPSE_SERVER_NAME: ${MATRIX_SERVER_NAME:-${DOMAIN}} - entrypoint: /bin/sh - command: - - -ec - - | - if [ ! -f /data/homeserver.yaml ]; then - /start.py generate - fi - python - <<'PY' - import os - import yaml - - path = "/data/homeserver.yaml" - with open(path, "r", encoding="utf-8") as handle: - config = yaml.safe_load(handle) or {} - - domain = os.environ["DOMAIN"] - server_name = os.environ.get("MATRIX_SERVER_NAME") or domain - config["server_name"] = server_name - config["public_baseurl"] = f"https://{domain}/" - config["database"] = { - "name": "psycopg2", - "args": { - "user": os.environ.get("MATRIX_POSTGRES_USER", "synapse"), - "password": os.environ["MATRIX_POSTGRES_PASSWORD"], - "database": os.environ.get("MATRIX_POSTGRES_DB", "synapse"), - "host": "matrix-db", - "cp_min": 5, - "cp_max": 10, - }, - } - config["redis"] = {"enabled": True, "host": "matrix-redis"} - config["registration_shared_secret"] = os.environ["MATRIX_REGISTRATION_SHARED_SECRET"] - config["turn_uris"] = [ - f"turn:{domain}:3478?transport=udp", - f"turn:{domain}:3478?transport=tcp", - ] - config["turn_shared_secret"] = os.environ["MATRIX_TURN_SHARED_SECRET"] - config["turn_user_lifetime"] = "1h" - config["enable_registration"] = False - config["experimental_features"] = { - **(config.get("experimental_features") or {}), - "msc3266_enabled": True, - "msc4222_enabled": True, - } - config["login_via_existing_session"] = { - "enabled": True, - "require_ui_auth": False, - "token_timeout": "5m", - } - config["max_event_delay_duration"] = "24h" - config["rc_message"] = {"per_second": 0.5, "burst_count": 30} - config["rc_delayed_event_mgmt"] = {"per_second": 1, "burst_count": 20} - - with open(path, "w", encoding="utf-8") as handle: - yaml.safe_dump(config, handle, sort_keys=False) - PY - exec /start.py - volumes: - - ./matrix/synapse:/data - healthcheck: - test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8008/_matrix/client/versions', timeout=2)\""] - interval: 10s - timeout: 5s - retries: 30 - start_period: 20s - labels: - - traefik.enable=true - - traefik.http.routers.fedeo-matrix.rule=Host(`${DOMAIN}`) && PathPrefix(`/_matrix`) - - traefik.http.routers.fedeo-matrix.entrypoints=websecure - - traefik.http.routers.fedeo-matrix.tls.certresolver=letsencrypt - - traefik.http.services.fedeo-matrix.loadbalancer.server.port=8008 - - traefik.docker.network=fedeo_web - networks: - - web - - internal - - matrix-well-known: - image: nginx:1.27-alpine - container_name: fedeo-matrix-well-known - restart: unless-stopped - command: - - /bin/sh - - -ec - - | - mkdir -p /usr/share/nginx/html/.well-known/matrix - cat >/usr/share/nginx/html/.well-known/matrix/client </usr/share/nginx/html/.well-known/matrix/server </tmp/livekit.yaml </app/config.json </tmp/livekit.yaml </tmp/livekit.yaml <-team:example.com` -- `#tenant--project-:example.com` -- `#tenant--ticket-:example.com` -- `#tenant--customer-:example.com` - -Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`. - -## Rechte und Rollen - -FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum. - -| FEDEO-Rolle | Matrix-Abbildung | -| --- | --- | -| Mandantenadmin | Space-Admin und Raumadmin | -| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen | -| Mitarbeitende | Mitglied mit Schreibrechten | -| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen | -| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten | - -Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können. - -## Chat - -Der Chat wird als erste Ausbaustufe umgesetzt. - -### Funktionen - -- Direktnachrichten -- Gruppenräume -- Mandanten-, Team-, Projekt- und Vorgangsräume -- Datei- und Bildanhänge -- Erwähnungen, Reaktionen und Lesestatus -- Suche in nicht verschlüsselten Räumen über den Homeserver -- lokale Suche in verschlüsselten Räumen über Client-Indizes -- Verknüpfung von Nachrichten mit FEDEO-Objekten - -### Integration in FEDEO - -FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen: - -- Matrix Raum-ID -- Matrix Event-ID -- FEDEO Objekt-Typ und Objekt-ID -- Zeitstempel -- beteiligter FEDEO-Nutzer -- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt - -So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört. - -## Audioanrufe - -Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button. - -### Anforderungen - -- WebRTC-Unterstützung im Browser -- STUN/TURN über coturn -- Geräteauswahl für Mikrofon und Lautsprecher -- Anrufbenachrichtigung im Web und mobil -- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend` - -Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden. - -## Videokonferenzen - -Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung. - -### Funktionen - -- Videokonferenzen aus Matrix-Räumen -- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten -- Bildschirmfreigabe -- Einladungslink für externe Gäste -- Wartebereich für externe Gäste -- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung -- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe - -### Konfiguration - -Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben. - -Beispiel: - -```json -{ - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.example.com/livekit/jwt" - } - ] -} -``` - -## Authentifizierung und Nutzerverwaltung - -FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern. - -### Empfohlener Ablauf - -1. Nutzer wird in FEDEO angelegt. -2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer. -3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu. -4. Login erfolgt über FEDEO SSO/OIDC. -5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe. - -Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein: - -```text -@u_:example.com -``` - -Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden. - -## Datenschutz und Compliance - -Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln. - -### Empfehlungen - -- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren. -- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden. -- Medienaufbewahrung mandantenweit konfigurierbar machen. -- Externe Gäste optisch klar kennzeichnen. -- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken. -- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben. -- Administrative Zugriffe protokollieren. -- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren. - -## Federation - -Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden. - -### Betriebsmodi - -| Modus | Beschreibung | Empfehlung | -| --- | --- | --- | -| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen | -| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation | -| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities | - -Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus. - -## Brücken zu anderen Systemen - -Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören. - -Mögliche spätere Erweiterungen: - -- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation -- Slack- oder Teams-Brücke für externe Projektpartner -- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung -- SIP-Brücke für Telefonie - -Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen. - -## FEDEO-Produktoberfläche - -Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein. - -### Globaler Kommunikationsbereich - -- Raumliste -- Direktnachrichten -- Suche -- Anrufe -- laufende Besprechungen -- Benachrichtigungen - -### Objektbezogene Kommunikation - -In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab: - -- zugeordneter Raum -- relevante Nachrichtenreferenzen -- Start von Chat, Anruf oder Besprechung -- Teilnehmerverwaltung entsprechend FEDEO-Rechten - -So bleibt Kommunikation dort, wo die Arbeit stattfindet. - -## Backend-Integration - -Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben: - -- Matrix-Nutzer provisionieren -- Spaces und Räume anlegen -- Raum-Mitgliedschaften synchronisieren -- Matrix-Event-Webhooks empfangen -- FEDEO-Objekte mit Matrix-Räumen verknüpfen -- Benachrichtigungseinstellungen verwalten -- Admin-Aktionen auditieren - -Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann. - -## Deployment-Erweiterung - -Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden: - -- `matrix-synapse` -- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank -- `redis` -- `coturn` -- `element-web` optional als Fallback-Client -- `element-call` -- `livekit` -- `matrix-rtc-jwt-service` - -Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen. - -## Monitoring - -Wichtige Kennzahlen: - -- aktive Nutzerinnen und Nutzer -- Anzahl Räume pro Mandant -- Nachrichtenrate -- Medien-Speicherverbrauch -- Zustellverzögerung -- fehlgeschlagene Anrufe -- LiveKit Paketverlust, Latenz und Teilnehmerzahl -- TURN-Nutzung -- Federation-Fehler - -Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein. - -## Risiken und Gegenmaßnahmen - -| Risiko | Gegenmaßnahme | -| --- | --- | -| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug | -| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien | -| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie | -| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten | -| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren | -| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte | - -## Umsetzung in Phasen - -### Phase 1: Fundament und Chat - -- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben -- FEDEO-Nutzer zu Matrix synchronisieren -- Mandanten-Spaces und erste Teamräume anlegen -- Chat im FEDEO-Frontend integrieren -- Benachrichtigungen und Raumreferenzen speichern - -### Phase 2: Objektbezogene Kommunikation - -- Räume automatisch für Projekte, Vorgänge und Kunden anlegen -- Kommunikations-Tab in FEDEO-Objekten ergänzen -- Rechteänderungen aus FEDEO nach Matrix synchronisieren -- externe Gäste einladen und kennzeichnen - -### Phase 3: Audio und Video - -- coturn bereitstellen -- MatrixRTC, Element Call und LiveKit integrieren -- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen -- Gäste-Links und Wartebereich umsetzen - -### Phase 4: Compliance und Skalierung - -- Aufbewahrungsrichtlinien pro Mandant -- Monitoring und Admin-Dashboards -- Federation-Allowlist -- optionale Brücken -- optionale Aufzeichnung mit Einwilligungsworkflow - -## Offene Entscheidungen - -- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden? -- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt? -- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge? -- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden? -- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer? - -## Quellen und Standards - -- Matrix Specification: https://spec.matrix.org/ -- Matrix Application Services: https://matrix.org/docs/older/application-services/ -- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/ -- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html -- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md -- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/ -- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/ diff --git a/docs/README.md b/docs/README.md index 5c6a9db..273c8b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,5 +6,4 @@ Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO. - [Bedienung](./bedienung/README.md) - [Fach- und Technikkonzept für das Lagersystem](./lagersystem-konzept.md) -- [Kommunikationslösung auf Basis des Matrix-Standards](./kommunikationslösung-matrix.md) - [Zentraler Push-Server für Selfhost-Instanzen](./zentraler-push-server.md) diff --git a/docs/kommunikationslösung-matrix.md b/docs/kommunikationslösung-matrix.md deleted file mode 100644 index 682c779..0000000 --- a/docs/kommunikationslösung-matrix.md +++ /dev/null @@ -1,371 +0,0 @@ -# Kommunikationslösung auf Basis des Matrix-Standards - -Dieser Entwurf beschreibt eine FEDEO-Kommunikationslösung für Chat, Anrufe und Videokonferenzen auf Basis des Matrix-Standards. Ziel ist eine souverän betreibbare Lösung, die Mandantenfähigkeit, Datenschutz, Rechteverwaltung und die bestehenden FEDEO-Workflows berücksichtigt. - -## Zielbild - -FEDEO erhält einen integrierten Kommunikationsbereich, der interne Zusammenarbeit und externe Kommunikation abdeckt: - -- Chat in Einzel-, Gruppen-, Projekt-, Vorgangs- und Kundenräumen -- Audioanrufe aus Direktchats, Gruppenräumen und Kontakten -- Videokonferenzen mit Bildschirmfreigabe und Einladungslinks -- Ende-zu-Ende-verschlüsselte private Kommunikation -- revisionsfähige Verknüpfung von relevanten Kommunikationsereignissen mit FEDEO-Objekten -- optional föderierte Kommunikation mit externen Matrix-Organisationen - -Matrix wird dabei nicht als isolierter Messenger betrieben, sondern als Kommunikationsschicht neben dem bestehenden FEDEO-Backend. - -## Empfohlene Architektur - -```text -Nutzerinnen und Nutzer - | - | FEDEO Web, Mobile App, optional Element Desktop/Mobile - v -FEDEO Frontend - | - | FEDEO API, SSO, Rechte, Objektkontext - v -FEDEO Backend - | - | Provisionierung, Webhooks, Audit-Metadaten - v -Matrix Homeserver - | - +-- PostgreSQL für Matrix-Daten - +-- Redis für Worker und Caches - +-- Medien-Repository für Anhänge - +-- TURN/STUN für direkte Medienverbindungen - +-- MatrixRTC / LiveKit SFU für Gruppenanrufe und Videokonferenzen -``` - -### Kernkomponenten - -| Komponente | Empfehlung | Aufgabe | -| --- | --- | --- | -| Matrix Homeserver | Synapse | Standardnaher, bewährter Homeserver mit guter Betriebsdokumentation | -| Matrix Client im FEDEO Web | Matrix JS SDK oder eingebetteter Element-Web-Ausschnitt | Chat, Raumliste, Nachrichten, Reaktionen, Anhänge | -| Mobile Integration | Matrix SDK über FEDEO Mobile oder Deep Link zu Element X | Pushfähige mobile Kommunikation | -| Identität | OIDC/SSO über FEDEO Auth, perspektivisch Matrix Authentication Service | Einheitlicher Login und zentrale Nutzerverwaltung | -| Audio/Video | MatrixRTC mit Element Call und LiveKit SFU | Moderne Anrufe und Videokonferenzen | -| NAT Traversal | coturn | STUN/TURN für stabile Medienverbindungen | -| Reverse Proxy | bestehender Traefik-Ansatz | TLS, Routing, `.well-known/matrix/*` | -| Administration | FEDEO Admin-Oberfläche plus Synapse Admin API | Nutzer, Räume, Richtlinien, Sperren | - -## Betriebsmodell - -Für FEDEO ist ein eigener Matrix-Homeserver pro Installation oder pro großer Betreiberinstanz sinnvoll. Der Matrix-Server sollte nicht öffentlich als offener Registrierungsserver betrieben werden. Nutzer werden ausschließlich durch FEDEO angelegt, aktualisiert und deaktiviert. - -Empfohlene Domains: - -- `app.example.com`: FEDEO Oberfläche -- `matrix.example.com`: Matrix Client-Server und Federation API -- `call.example.com`: Element Call / MatrixRTC -- `livekit.example.com`: LiveKit SFU -- `turn.example.com`: TURN/STUN - -Die öffentliche Matrix-Serverkennung kann trotzdem `example.com` lauten. Dafür werden `.well-known/matrix/client` und `.well-known/matrix/server` über Traefik ausgeliefert. - -## Mandantenmodell - -Matrix selbst ist raumbasiert, FEDEO ist mandantenbasiert. Deshalb sollte FEDEO die Mandantenlogik explizit auf Matrix-Räume und Spaces abbilden. - -### Räume und Spaces - -- Pro FEDEO-Mandant wird ein Matrix Space angelegt. -- Projekte, Vorgänge, Helpdesk-Konversationen, interne Teams und Kundenkontakte werden als Räume im Mandanten-Space geführt. -- Direkträume werden nutzerbezogen angelegt, aber über FEDEO mandantengebunden sichtbar gemacht. -- Externe Räume erhalten einen klaren Status, zum Beispiel `intern`, `extern`, `kunde`, `lieferant`. - -### Raumalias-Konvention - -Beispiele: - -- `#tenant--team:example.com` -- `#tenant--project-:example.com` -- `#tenant--ticket-:example.com` -- `#tenant--customer-:example.com` - -Interne technische IDs sollten nicht als sichtbarer Anzeigename genutzt werden. Nutzerinnen und Nutzer sehen sprechende Namen wie `Projekt: Website Relaunch` oder `Kunde: Muster GmbH`. - -## Rechte und Rollen - -FEDEO bleibt führend für Berechtigungen. Matrix übernimmt die technische Durchsetzung im Raum. - -| FEDEO-Rolle | Matrix-Abbildung | -| --- | --- | -| Mandantenadmin | Space-Admin und Raumadmin | -| Teamleitung | Moderatorin oder Moderator in Team- und Projekträumen | -| Mitarbeitende | Mitglied mit Schreibrechten | -| Externe Kontakte | Eingeschränkte Mitgliedschaft in ausgewählten Räumen | -| Automationen | Application-Service- oder Bot-Nutzer mit minimalen Rechten | - -Änderungen an Rollen, Teams oder Mandantenzugehörigkeiten lösen im FEDEO-Backend eine Synchronisation mit Matrix aus. Beim Entzug eines Zugriffs wird die Person aus den betroffenen Räumen entfernt. Bei Ende-zu-Ende-verschlüsselten Räumen muss zusätzlich berücksichtigt werden, dass bereits erhaltene Nachrichten auf Geräten verbleiben können. - -## Chat - -Der Chat wird als erste Ausbaustufe umgesetzt. - -### Funktionen - -- Direktnachrichten -- Gruppenräume -- Mandanten-, Team-, Projekt- und Vorgangsräume -- Datei- und Bildanhänge -- Erwähnungen, Reaktionen und Lesestatus -- Suche in nicht verschlüsselten Räumen über den Homeserver -- lokale Suche in verschlüsselten Räumen über Client-Indizes -- Verknüpfung von Nachrichten mit FEDEO-Objekten - -### Integration in FEDEO - -FEDEO sollte keine vollständige Kopie aller Nachrichten in der eigenen Datenbank speichern. Stattdessen speichert FEDEO nur Referenzen: - -- Matrix Raum-ID -- Matrix Event-ID -- FEDEO Objekt-Typ und Objekt-ID -- Zeitstempel -- beteiligter FEDEO-Nutzer -- optionale Vorschau, falls Datenschutzrichtlinie dies erlaubt - -So bleibt Matrix das Kommunikationssystem, während FEDEO nachvollziehen kann, welche Kommunikation zu welchem Objekt gehört. - -## Audioanrufe - -Einzelanrufe können direkt über Matrix-VoIP in Direktchats gestartet werden. Der FEDEO-Client zeigt dafür in Kontakt-, Kunden-, Mitarbeitenden- und Chatansichten einen Anruf-Button. - -### Anforderungen - -- WebRTC-Unterstützung im Browser -- STUN/TURN über coturn -- Geräteauswahl für Mikrofon und Lautsprecher -- Anrufbenachrichtigung im Web und mobil -- Statusanzeige `verfügbar`, `beschäftigt`, `im Anruf`, `abwesend` - -Für klassische Telefonie kann später ein SIP-Gateway ergänzt werden. Das sollte jedoch getrennt von der ersten Matrix-Einführung betrachtet werden, damit Chat und WebRTC-Kommunikation nicht durch Telefoniekomplexität ausgebremst werden. - -## Videokonferenzen - -Für Gruppenanrufe und Videokonferenzen wird MatrixRTC mit Element Call und LiveKit empfohlen. Matrix übernimmt dabei Raumzustand, Identität, Berechtigungen und Signalisierung; LiveKit übernimmt als SFU die effiziente Medienverteilung. - -### Funktionen - -- Videokonferenzen aus Matrix-Räumen -- spontane Besprechungen aus Projekten, Vorgängen oder Kundenakten -- Bildschirmfreigabe -- Einladungslink für externe Gäste -- Wartebereich für externe Gäste -- Moderationsrechte für Stummschalten, Entfernen und Raumverwaltung -- optionale Aufzeichnung erst in einer späteren, gesondert freizugebenden Ausbaustufe - -### Konfiguration - -Clients finden den MatrixRTC-Dienst über `.well-known/matrix/client`. Dort wird der LiveKit-JWT-Dienst als `org.matrix.msc4143.rtc_foci` angekündigt. Diese Datei muss öffentlich lesbar sein, als JSON ausgeliefert werden und CORS für Webclients erlauben. - -Beispiel: - -```json -{ - "m.homeserver": { - "base_url": "https://matrix.example.com" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.example.com/livekit/jwt" - } - ] -} -``` - -## Authentifizierung und Nutzerverwaltung - -FEDEO sollte Identität und Lebenszyklus der Nutzer zentral steuern. - -### Empfohlener Ablauf - -1. Nutzer wird in FEDEO angelegt. -2. FEDEO erzeugt oder aktualisiert den Matrix-Nutzer. -3. FEDEO weist den Nutzer den passenden Spaces und Räumen zu. -4. Login erfolgt über FEDEO SSO/OIDC. -5. Deaktivierung in FEDEO deaktiviert auch den Matrix-Zugang und entfernt Raumzugriffe. - -Die Matrix User-ID sollte stabil und nicht personenbezogen änderungsanfällig sein: - -```text -@u_:example.com -``` - -Der Anzeigename kann weiterhin den echten Namen enthalten und bei Änderungen synchronisiert werden. - -## Datenschutz und Compliance - -Matrix erlaubt starke Datenschutzkonzepte, erfordert aber klare Betriebsregeln. - -### Empfehlungen - -- Ende-zu-Ende-Verschlüsselung für Direktnachrichten und vertrauliche Projekträume aktivieren. -- Nicht verschlüsselte Räume nur dort nutzen, wo serverseitige Suche, Archivierung oder Compliance-Funktionen ausdrücklich benötigt werden. -- Medienaufbewahrung mandantenweit konfigurierbar machen. -- Externe Gäste optisch klar kennzeichnen. -- Federation standardmäßig deaktivieren oder auf erlaubte Domains beschränken. -- Aufzeichnungen von Videokonferenzen nur mit expliziter Einwilligung und sichtbarem Status erlauben. -- Administrative Zugriffe protokollieren. -- Klare Löschfristen für Räume, Anhänge und Audit-Referenzen definieren. - -## Federation - -Matrix kann mit anderen Homeservern föderieren. Für FEDEO sollte Federation als kontrollierbare Option umgesetzt werden. - -### Betriebsmodi - -| Modus | Beschreibung | Empfehlung | -| --- | --- | --- | -| geschlossen | Keine Federation, nur interne Nutzer und explizite Gäste | Standard für kleine Installationen | -| allowlist | Federation nur mit freigegebenen Domains | Empfehlung für B2B-Kommunikation | -| offen | Federation mit beliebigen Matrix-Servern | Nur für bewusst öffentliche Communities | - -Für steuer-, kunden- und projektnahe Kommunikation ist `allowlist` der beste Zielmodus. - -## Brücken zu anderen Systemen - -Matrix unterstützt Brücken zu anderen Kommunikationsdiensten. Für FEDEO sind Brücken nützlich, sollten aber nicht zur ersten Produktstufe gehören. - -Mögliche spätere Erweiterungen: - -- E-Mail-Brücke für Helpdesk- oder Kundenkommunikation -- Slack- oder Teams-Brücke für externe Projektpartner -- WhatsApp- oder SMS-Brücke nur nach gesonderter Datenschutzprüfung -- SIP-Brücke für Telefonie - -Brücken müssen pro Mandant aktivierbar sein und brauchen klare Hinweise, welche Daten an externe Dienste fließen. - -## FEDEO-Produktoberfläche - -Die Kommunikation sollte in FEDEO an zwei Stellen sichtbar sein. - -### Globaler Kommunikationsbereich - -- Raumliste -- Direktnachrichten -- Suche -- Anrufe -- laufende Besprechungen -- Benachrichtigungen - -### Objektbezogene Kommunikation - -In Projekten, Kunden, Vorgängen, Helpdesk-Tickets und Dokumenten erscheint ein Kommunikations-Tab: - -- zugeordneter Raum -- relevante Nachrichtenreferenzen -- Start von Chat, Anruf oder Besprechung -- Teilnehmerverwaltung entsprechend FEDEO-Rechten - -So bleibt Kommunikation dort, wo die Arbeit stattfindet. - -## Backend-Integration - -Das FEDEO-Backend erhält ein Kommunikationsmodul mit folgenden Aufgaben: - -- Matrix-Nutzer provisionieren -- Spaces und Räume anlegen -- Raum-Mitgliedschaften synchronisieren -- Matrix-Event-Webhooks empfangen -- FEDEO-Objekte mit Matrix-Räumen verknüpfen -- Benachrichtigungseinstellungen verwalten -- Admin-Aktionen auditieren - -Technisch kann dies über Matrix Admin API, Client-Server API und Application Services erfolgen. Für Automationen empfiehlt sich ein eigener Application Service, weil er reservierte Nutzer- und Raum-Namensräume sauber verwalten kann. - -## Deployment-Erweiterung - -Der bestehende Docker-/Traefik-Ansatz kann um folgende Dienste erweitert werden: - -- `matrix-synapse` -- `matrix-db` oder gemeinsame PostgreSQL-Instanz mit getrennter Datenbank -- `redis` -- `coturn` -- `element-web` optional als Fallback-Client -- `element-call` -- `livekit` -- `matrix-rtc-jwt-service` - -Für produktive Installationen sollte Matrix eine eigene PostgreSQL-Datenbank erhalten. Medien sollten in S3-kompatiblen Speicher ausgelagert werden, damit große Anhänge und Konferenzartefakte nicht den Applikationsserver füllen. - -## Monitoring - -Wichtige Kennzahlen: - -- aktive Nutzerinnen und Nutzer -- Anzahl Räume pro Mandant -- Nachrichtenrate -- Medien-Speicherverbrauch -- Zustellverzögerung -- fehlgeschlagene Anrufe -- LiveKit Paketverlust, Latenz und Teilnehmerzahl -- TURN-Nutzung -- Federation-Fehler - -Logs von FEDEO, Synapse, LiveKit, coturn und Traefik sollten über eine gemeinsame Korrelation, zum Beispiel Request-ID oder Nutzer-ID, untersuchbar sein. - -## Risiken und Gegenmaßnahmen - -| Risiko | Gegenmaßnahme | -| --- | --- | -| Komplexität durch zwei Systeme | FEDEO bleibt führend für Nutzer, Rechte und Objektbezug | -| Datenschutz bei externen Räumen | Externe Kennzeichnung, Federation-Allowlist, Mandantenrichtlinien | -| E2EE erschwert Suche und Archivierung | Raumtyp bewusst wählen, lokale Suche, Metadatenreferenzen statt Vollkopie | -| Medienverbindungen scheitern in Firmennetzen | coturn sauber betreiben, UDP und TCP/TLS-Fallback anbieten | -| Betriebskosten durch Video | LiveKit skalierbar betreiben, Limits pro Mandant definieren | -| Gästezugriff wird unübersichtlich | Einladungslinks mit Ablaufdatum, Wartebereich, Moderationsrechte | - -## Umsetzung in Phasen - -### Phase 1: Fundament und Chat - -- Synapse mit PostgreSQL, Redis, Traefik und `.well-known` betreiben -- FEDEO-Nutzer zu Matrix synchronisieren -- Mandanten-Spaces und erste Teamräume anlegen -- Chat im FEDEO-Frontend integrieren -- Benachrichtigungen und Raumreferenzen speichern - -### Phase 2: Objektbezogene Kommunikation - -- Räume automatisch für Projekte, Vorgänge und Kunden anlegen -- Kommunikations-Tab in FEDEO-Objekten ergänzen -- Rechteänderungen aus FEDEO nach Matrix synchronisieren -- externe Gäste einladen und kennzeichnen - -### Phase 3: Audio und Video - -- coturn bereitstellen -- MatrixRTC, Element Call und LiveKit integrieren -- Anruf- und Videobuttons in Chat, Kontakten und Projekten ergänzen -- Gäste-Links und Wartebereich umsetzen - -### Phase 4: Compliance und Skalierung - -- Aufbewahrungsrichtlinien pro Mandant -- Monitoring und Admin-Dashboards -- Federation-Allowlist -- optionale Brücken -- optionale Aufzeichnung mit Einwilligungsworkflow - -## Offene Entscheidungen - -- Soll Federation initial deaktiviert oder direkt mit Allowlist ausgeliefert werden? -- Welche Räume müssen serverseitig durchsuchbar sein und bleiben deshalb unverschlüsselt? -- Sollen externe Gäste Matrix-Konten erhalten oder nur temporäre Konferenzzugänge? -- Wird Element als sichtbarer Fallback-Client angeboten oder soll alles primär in FEDEO stattfinden? -- Welche Mandantenlimits gelten für Speicher, Teilnehmerzahl und Videodauer? - -## Quellen und Standards - -- Matrix Specification: https://spec.matrix.org/ -- Matrix Application Services: https://matrix.org/docs/older/application-services/ -- Matrix Bridges: https://www.matrix.org/docs/communities/bridging/ -- Synapse Worker-Dokumentation: https://matrix-org.github.io/synapse/develop/workers.html -- Element Call Self-Hosting: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md -- Element MatrixRTC Konfiguration: https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/ -- LiveKit Self-Hosting: https://docs.livekit.io/transport/self-hosting/ diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index b89d439..cab9fdc 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -352,8 +352,8 @@ const links = computed(() => { icon: "i-heroicons-phone", }, { - label: "Matrix-Setup", - to: "/communication", + label: "Chat", + to: "/communication/chat", icon: "i-heroicons-chat-bubble-left-right", }, featureEnabled("export") ? { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 8b07625..ecfffb3 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -81,7 +81,6 @@ export default defineNuxtConfig({ public: { apiBase: '', pdfLicense: '', - matrixElementUrl: process.env.NUXT_PUBLIC_MATRIX_ELEMENT_URL || 'http://localhost:8080' } }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9a5ecd..c20dc17 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,7 +74,6 @@ "image-js": "^1.1.0", "leaflet": "^1.9.4", "license-checker": "^25.0.1", - "livekit-client": "^2.19.0", "maplibre-gl": "^4.7.0", "nuxt-editorjs": "^1.0.4", "nuxt-viewport": "^2.0.6", @@ -1867,12 +1866,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bufbuild/protobuf": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", - "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, "node_modules/@capacitor-community/bluetooth-le": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-7.3.0.tgz", @@ -3167,21 +3160,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@livekit/mutex": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", - "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", - "license": "Apache-2.0" - }, - "node_modules/@livekit/protocol": { - "version": "1.45.8", - "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz", - "integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==", - "license": "Apache-2.0", - "dependencies": { - "@bufbuild/protobuf": "^1.10.0" - } - }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -3763,18 +3741,6 @@ } } }, - "node_modules/@nuxt/cli/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@nuxt/devalue": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", @@ -8719,13 +8685,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/dom-mediacapture-record": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", - "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", - "license": "MIT", - "peer": true - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -12221,6 +12180,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -14238,15 +14198,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", @@ -14822,26 +14773,6 @@ "dev": true, "license": "MIT" }, - "node_modules/livekit-client": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz", - "integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==", - "license": "Apache-2.0", - "dependencies": { - "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.45.8", - "events": "^3.3.0", - "jose": "^6.1.0", - "loglevel": "^1.9.2", - "sdp-transform": "^2.15.0", - "tslib": "2.8.1", - "typed-emitter": "^2.1.0", - "webrtc-adapter": "9.0.5" - }, - "peerDependencies": { - "@types/dom-mediacapture-record": "^1" - } - }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", @@ -14916,19 +14847,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -18523,16 +18441,6 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -18690,21 +18598,6 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, - "node_modules/sdp": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", - "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", - "license": "MIT" - }, - "node_modules/sdp-transform": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", - "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", - "license": "MIT", - "bin": { - "sdp-verify": "checker.js" - } - }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -20269,15 +20162,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", - "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", - "license": "MIT", - "optionalDependencies": { - "rxjs": "*" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -21832,19 +21716,6 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, - "node_modules/webrtc-adapter": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", - "integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", - "license": "BSD-3-Clause", - "dependencies": { - "sdp": "^3.2.0" - }, - "engines": { - "node": ">=6.0.0", - "npm": ">=3.10.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1dbf304..1dd119b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -87,7 +87,6 @@ "image-js": "^1.1.0", "leaflet": "^1.9.4", "license-checker": "^25.0.1", - "livekit-client": "^2.19.0", "maplibre-gl": "^4.7.0", "nuxt-editorjs": "^1.0.4", "nuxt-viewport": "^2.0.6", diff --git a/frontend/pages/administration/system.vue b/frontend/pages/administration/system.vue index 2bbf80f..22a96fe 100644 --- a/frontend/pages/administration/system.vue +++ b/frontend/pages/administration/system.vue @@ -13,7 +13,6 @@ const serviceLabels: Record = { backend: "Backend", database: "Datenbank", nodeExporter: "Node Exporter", - matrix: "Matrix", minio: "Dateispeicher", } diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 24226f0..69ad250 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -1,2492 +1,305 @@ diff --git a/frontend/pages/communication/index.vue b/frontend/pages/communication/index.vue index a68184f..1fc4944 100644 --- a/frontend/pages/communication/index.vue +++ b/frontend/pages/communication/index.vue @@ -1,501 +1,7 @@ diff --git a/matrix/README.md b/matrix/README.md deleted file mode 100644 index 3077166..0000000 --- a/matrix/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# Matrix-Stack in der FEDEO Compose - -Der Matrix-Stack liegt in derselben `docker-compose.yml` wie FEDEO und ist über das Compose-Profil `matrix` aktivierbar. - -## Enthaltene Dienste - -- `matrix-db`: PostgreSQL für Synapse -- `matrix-redis`: Redis für Synapse und LiveKit -- `matrix-synapse`: Matrix Homeserver -- `matrix-well-known`: Auslieferung von `.well-known/matrix/client` und `.well-known/matrix/server` -- `matrix-turn`: coturn für stabile WebRTC-Verbindungen -- `matrix-livekit`: LiveKit SFU für MatrixRTC-Konferenzen -- `matrix-rtc-jwt`: MatrixRTC Authorization Service für LiveKit-JWTs - -## Vorbereitung - -Lege im Repo eine `.env` auf Basis von `.env.example` an und passe mindestens diese Werte an: - -- `MATRIX_SERVER_NAME` -- `MATRIX_HOMESERVER_HOST` -- `MATRIX_RTC_HOST` -- `MATRIX_TURN_HOST` -- `MATRIX_POSTGRES_PASSWORD` -- `MATRIX_TURN_SHARED_SECRET` -- `LIVEKIT_KEY` -- `LIVEKIT_SECRET` - -Passe außerdem die Dateien in `matrix/well-known/` an, falls die Domains nicht `fedeo.de`, `matrix.fedeo.de` und `call.fedeo.de` heißen. - -## Synapse-Konfiguration erzeugen - -Synapse benötigt vor dem ersten Start eine generierte `homeserver.yaml`. Der Befehl bleibt innerhalb derselben Compose: - -```bash -docker compose --profile matrix run --rm \ - -e SYNAPSE_SERVER_NAME="${MATRIX_SERVER_NAME}" \ - -e SYNAPSE_REPORT_STATS=no \ - matrix-synapse generate -``` - -Danach `matrix/synapse/homeserver.yaml` prüfen und mindestens diese Punkte setzen: - -```yaml -public_baseurl: "https://matrix.fedeo.de/" - -database: - name: psycopg2 - args: - user: synapse - password: "" - database: synapse - host: matrix-db - cp_min: 5 - cp_max: 10 - -redis: - enabled: true - host: matrix-redis - -turn_uris: - - "turn::3478?transport=udp" - - "turn::3478?transport=tcp" -turn_shared_secret: "" -turn_user_lifetime: "1h" - -experimental_features: - msc3266_enabled: true - msc4222_enabled: true - -max_event_delay_duration: 24h -rc_message: - per_second: 0.5 - burst_count: 30 -rc_delayed_event_mgmt: - per_second: 1 - burst_count: 20 -``` - -## Start - -```bash -docker compose --profile matrix up -d -``` - -Ohne Profil startet weiterhin nur der bisherige FEDEO-Stack: - -```bash -docker compose up -d -``` - -## Hinweise - -- Die Matrix-Services sind bewusst im bestehenden Compose-Stack definiert, damit FEDEO nicht in mehrere Deployment-Dateien zerfällt. -- Die aktuellen Ports für TURN und LiveKit müssen auf der Firewall des Servers freigegeben werden. -- Federation sollte erst nach einer expliziten Entscheidung geöffnet werden. Für B2B-Kommunikation ist eine Allowlist sinnvoll. -- Die Werte in `.env.example` sind Platzhalter und nicht produktionssicher. - -## Lokaler Entwicklungsstack - -Für lokale Entwicklung gibt es zusätzlich das Profil `matrix-dev`. Es nutzt direkte Localhost-Ports und braucht keine öffentlichen Domains, kein ACME und keine Traefik-Router. - -Lokale Dienste: - -- Synapse: `http://localhost:8008` -- Element Web: `http://localhost:8080` -- MatrixRTC JWT-Service: `http://localhost:8081` -- LiveKit: `ws://localhost:7880` -- TURN: `localhost:3478` - -### Lokale Synapse-Konfiguration erzeugen - -```bash -docker compose --profile matrix-dev run --rm \ - -e SYNAPSE_SERVER_NAME=localhost \ - -e SYNAPSE_REPORT_STATS=no \ - matrix-dev-synapse generate -``` - -Danach `matrix/dev/synapse/homeserver.yaml` für die lokale Compose anpassen: - -```yaml -public_baseurl: "http://localhost:8008/" - -database: - name: psycopg2 - args: - user: synapse - password: "synapse-dev-password" - database: synapse - host: matrix-dev-db - cp_min: 5 - cp_max: 10 - -redis: - enabled: true - host: matrix-dev-redis - -enable_registration: true -enable_registration_without_verification: true - -turn_uris: - - "turn:localhost:3478?transport=udp" - - "turn:localhost:3478?transport=tcp" -turn_shared_secret: "matrix-dev-turn-secret" -turn_user_lifetime: "1h" - -experimental_features: - msc3266_enabled: true - msc4222_enabled: true -``` - -### Lokalen Stack starten - -```bash -docker compose --profile matrix-dev up -d \ - matrix-dev-db \ - matrix-dev-redis \ - matrix-dev-synapse \ - matrix-dev-turn \ - matrix-dev-livekit \ - matrix-dev-rtc-jwt \ - matrix-dev-element -``` - -Einen lokalen Admin-Nutzer kannst du danach im Synapse-Container anlegen: - -```bash -docker compose --profile matrix-dev exec matrix-dev-synapse \ - register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008 -``` - -Anschließend Element Web unter `http://localhost:8080` öffnen und mit dem lokalen Matrix-Nutzer anmelden. - -Wenn FEDEO selbst parallel lokal laufen soll, starte die FEDEO-Dienste separat wie gewohnt. Der lokale Matrix-Stack ist absichtlich über direkte Ports erreichbar, damit er unabhängig von DNS, TLS und Traefik getestet werden kann. - -## Erste FEDEO-Backend-Integration - -Das Backend stellt geschützte Matrix-Endpunkte unter `/api/communication/matrix/*` bereit: - -- `GET /api/communication/matrix/status`: prüft Konfiguration und Erreichbarkeit des Matrix-Homeservers -- `GET /api/communication/matrix/me`: zeigt die aus dem FEDEO-Nutzer abgeleitete Matrix-ID -- `POST /api/communication/matrix/me/provision`: legt den Matrix-Account für den angemeldeten FEDEO-Nutzer per Synapse-Shared-Secret-Registrierung an - -Für lokale Provisionierung muss `MATRIX_REGISTRATION_SHARED_SECRET` aus `matrix/dev/synapse/homeserver.yaml` in der Backend-Umgebung gesetzt werden. Die lokale Synapse-Konfiguration ist absichtlich nicht versioniert, weil sie Secrets enthält. - -In der lokalen Entwicklung liest das Backend dieses Secret als Fallback direkt aus `matrix/dev/synapse/homeserver.yaml`, sofern `NODE_ENV` nicht `production` ist. Auf Servern muss das Secret weiterhin explizit über die Umgebung oder das Secret-Management gesetzt werden. - -Für den eingebetteten Element-Login in FEDEO muss in der lokalen Synapse-Konfiguration außerdem der kurzlebige Login-Token-Flow aktiv sein: - -```yaml -login_via_existing_session: - enabled: true - require_ui_auth: false - token_timeout: "5m" -``` - -Nach einer Änderung an `matrix/dev/synapse/homeserver.yaml` muss `matrix-dev-synapse` neu gestartet werden. diff --git a/matrix/dev/element-config.json b/matrix/dev/element-config.json deleted file mode 100644 index ff240ec..0000000 --- a/matrix/dev/element-config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "default_server_config": { - "m.homeserver": { - "base_url": "http://localhost:8008", - "server_name": "localhost" - } - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "http://localhost:8081" - } - ], - "disable_custom_urls": false, - "disable_guests": true, - "brand": "FEDEO Matrix Dev", - "default_theme": "light", - "features": { - "feature_video_rooms": true - } -} diff --git a/matrix/well-known/client b/matrix/well-known/client deleted file mode 100644 index 8be3bb0..0000000 --- a/matrix/well-known/client +++ /dev/null @@ -1,11 +0,0 @@ -{ - "m.homeserver": { - "base_url": "https://matrix.fedeo.de" - }, - "org.matrix.msc4143.rtc_foci": [ - { - "type": "livekit", - "livekit_service_url": "https://call.fedeo.de/livekit/jwt" - } - ] -} diff --git a/matrix/well-known/server b/matrix/well-known/server deleted file mode 100644 index 0703b40..0000000 --- a/matrix/well-known/server +++ /dev/null @@ -1,3 +0,0 @@ -{ - "m.server": "matrix.fedeo.de:443" -} diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index aaed6e7..b670353 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -6,7 +6,7 @@ import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; -import { fetchMatrixUnreadCounts } from '@/src/lib/api'; +import { fetchChatUnreadCounts } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; export default function TabLayout() { @@ -22,7 +22,7 @@ export default function TabLayout() { } try { - const unread = await fetchMatrixUnreadCounts(token); + const unread = await fetchChatUnreadCounts(token); const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0); setCommunicationUnread(total); await Notifications.setBadgeCountAsync(total); diff --git a/mobile/app/(tabs)/communication.tsx b/mobile/app/(tabs)/communication.tsx index f9725fa..17c6611 100644 --- a/mobile/app/(tabs)/communication.tsx +++ b/mobile/app/(tabs)/communication.tsx @@ -1,15 +1,12 @@ -import * as DocumentPicker from 'expo-document-picker'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, - Alert, FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, - ScrollView, StyleSheet, Text, TextInput, @@ -17,148 +14,58 @@ import { } from 'react-native'; import { - createMatrixRoom, - deleteMatrixMessage, - editMatrixMessage, - fetchMatrixIdentity, - fetchMatrixMembers, - fetchMatrixMessages, - fetchMatrixRooms, - fetchMatrixStatus, - fetchMatrixUsers, - inviteMatrixMember, - markMatrixRoomRead, - MatrixMember, - MatrixMessage, - MatrixRoom, - MatrixStatus, - MatrixUser, - provisionMatrixRoom, - provisionMatrixUser, - reactToMatrixMessage, - removeMatrixMember, - sendMatrixMessage, - syncMatrixMembers, - syncMatrixRoom, - uploadMatrixAttachment, + ChatMember, + ChatMessage, + ChatRoom, + createChatRoom, + fetchChatMembers, + fetchChatMessages, + fetchChatRooms, + markChatRoomRead, + provisionChatRoom, + sendChatMessage, + syncChatRoom, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; -const REACTION_PRESETS = ['👍', '✅', '👀', '🙏']; - -type DialogMode = 'create-room' | 'edit-message' | 'invite-member' | null; function normalizeRoomKey(value: string): string { - return value - .trim() - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 48); + return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48); } -function messageTime(value: MatrixMessage['timestamp']): string { +function messageTime(value: ChatMessage['timestamp']): string { if (!value) return ''; - const date = typeof value === 'number' ? new Date(value) : new Date(value); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleString('de-DE', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); + return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); } -function messagePreview(message: MatrixMessage): string { - return message.body || message.attachment?.fileName || 'Nachricht'; -} - -function sortMessages(messages: MatrixMessage[]): MatrixMessage[] { - return [...messages].sort((a, b) => { - const left = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const right = b.timestamp ? new Date(b.timestamp).getTime() : 0; - return left - right; - }); -} - -function mergeMessages(current: MatrixMessage[], incoming: MatrixMessage[]): MatrixMessage[] { - const byId = new Map(); +function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { + const byId = new Map(); for (const message of current) byId.set(message.id, message); - for (const message of incoming || []) byId.set(message.id, { ...byId.get(message.id), ...message }); - return sortMessages(Array.from(byId.values())); -} - -function applyReplacements(current: MatrixMessage[], replacements: MatrixMessage[] = []): MatrixMessage[] { - if (!replacements.length) return current; - const byTarget = new Map(); - for (const replacement of replacements) { - const targetId = String(replacement.targetEventId || replacement.replyToEventId || replacement.id || ''); - if (targetId) byTarget.set(targetId, replacement); - } - - return current.map((message) => { - const replacement = byTarget.get(message.id); - if (!replacement) return message; - return { - ...message, - body: replacement.body ?? message.body, - edited: true, - timestamp: replacement.timestamp || message.timestamp, - }; - }); -} - -function applyRedactions( - current: MatrixMessage[], - redactions: { redacts?: string; eventId?: string; targetEventId?: string }[] = [] -): MatrixMessage[] { - if (!redactions.length) return current; - const ids = new Set(redactions.map((item) => item.redacts || item.eventId || item.targetEventId).filter(Boolean)); - return current.filter((message) => !ids.has(message.id)); -} - -function applyReactions( - current: MatrixMessage[], - reactions: { targetEventId?: string; key: string; count?: number; own?: boolean; senders?: string[] }[] = [] -): MatrixMessage[] { - if (!reactions.length) return current; - return current.map((message) => { - const next = reactions.filter((reaction) => reaction.targetEventId === message.id); - if (!next.length) return message; - return { ...message, reactions: next }; - }); + for (const message of incoming) byId.set(message.id, message); + return Array.from(byId.values()).sort((a, b) => a.id - b.id); } export default function CommunicationScreen() { const { token } = useAuth(); - const messageListRef = useRef>(null); - const [status, setStatus] = useState(null); - const [matrixUserId, setMatrixUserId] = useState(''); - const [rooms, setRooms] = useState([]); - const [members, setMembers] = useState([]); - const [users, setUsers] = useState([]); - const [messages, setMessages] = useState([]); + const listRef = useRef>(null); + const [rooms, setRooms] = useState([]); + const [members, setMembers] = useState([]); + const [messages, setMessages] = useState([]); const [activeRoomKey, setActiveRoomKey] = useState('allgemein'); - const [syncSince, setSyncSince] = useState(); const [draft, setDraft] = useState(''); const [search, setSearch] = useState(''); - const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [sending, setSending] = useState(false); - const [provisioning, setProvisioning] = useState(false); + const [error, setError] = useState(null); const [membersOpen, setMembersOpen] = useState(false); - const [replyTarget, setReplyTarget] = useState(null); - const [editingMessage, setEditingMessage] = useState(null); - const [dialogMode, setDialogMode] = useState(null); + const [createOpen, setCreateOpen] = useState(false); const [roomName, setRoomName] = useState(''); const [roomTopic, setRoomTopic] = useState(''); const [roomKey, setRoomKey] = useState(''); - const [dialogText, setDialogText] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(''); + const afterIdRef = useRef(0); const activeRoom = useMemo( () => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null, @@ -167,407 +74,135 @@ export default function CommunicationScreen() { const visibleRooms = useMemo(() => { const query = search.trim().toLowerCase(); - const base = [...rooms].sort((a, b) => { - const groupCompare = String(a.group || '').localeCompare(String(b.group || '')); - if (groupCompare !== 0) return groupCompare; - return String(a.name || a.key).localeCompare(String(b.name || b.key)); - }); - if (!query) return base; - return base.filter((room) => - [room.name, room.key, room.topic, room.email, room.projectNumber] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(query)) - ); + return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber] + .filter(Boolean).some((value) => String(value).toLowerCase().includes(query))); }, [rooms, search]); - const inviteCandidates = useMemo(() => { - const existing = new Set(members.map((member) => member.matrixUserId)); - return users.filter((user) => !existing.has(user.matrixUserId)); - }, [members, users]); - - const clearRoomUnread = useCallback((roomKey: string) => { - setRooms((current) => - current.map((room) => (room.key === roomKey ? { ...room, unread: 0, mentions: 0 } : room)) - ); - }, []); - const loadRooms = useCallback(async () => { + if (!token) return []; + const result = await fetchChatRooms(token); + setRooms(result); + return result; + }, [token]); + + const loadRoom = useCallback(async (key: string) => { if (!token) return; - const [nextStatus, identity, nextRooms, nextUsers] = await Promise.all([ - fetchMatrixStatus(token), - fetchMatrixIdentity(token), - fetchMatrixRooms(token), - fetchMatrixUsers(token), + const [nextMessages, nextMembers] = await Promise.all([ + fetchChatMessages(token, key), + fetchChatMembers(token, key), ]); - setStatus(nextStatus); - setMatrixUserId(identity.matrixUserId || ''); - setRooms(nextRooms); + setMessages(nextMessages); + setMembers(nextMembers); + afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0; + await markChatRoomRead(token, key, afterIdRef.current); + }, [token]); - if (!nextRooms.some((room) => room.key === activeRoomKey) && nextRooms[0]) { - setActiveRoomKey(nextRooms[0].key); - } - - setUsers(nextUsers); - }, [activeRoomKey, token]); - - const loadRoomContent = useCallback( - async (roomKeyToLoad: string, showSpinner = true) => { - if (!token || !roomKeyToLoad) return; - if (showSpinner) setLoading(true); - setError(null); - - try { - const [nextMessages, nextMembers, sync] = await Promise.all([ - fetchMatrixMessages(token, roomKeyToLoad), - fetchMatrixMembers(token, roomKeyToLoad), - syncMatrixRoom(token, roomKeyToLoad, undefined, true), - ]); - const merged = mergeMessages(nextMessages, sync.messages || []); - setMessages(sortMessages(merged)); - setMembers(sync.members || nextMembers); - setSyncSince(sync.nextBatch); - const last = merged.at(-1); - if (last?.id) { - await markMatrixRoomRead(token, roomKeyToLoad, last.id); - clearRoomUnread(roomKeyToLoad); - } - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Kommunikation konnte nicht geladen werden.'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, - [clearRoomUnread, token] - ); - - const refreshAll = useCallback( - async (showSpinner = true) => { - if (!token) return; - if (showSpinner) setLoading(true); - setError(null); - - try { - await loadRooms(); - await loadRoomContent(activeRoomKey, false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Matrix-Kommunikation konnte nicht geladen werden.'); - setLoading(false); - setRefreshing(false); - } - }, - [activeRoomKey, loadRoomContent, loadRooms, token] - ); - - const pollSync = useCallback(async () => { - if (!token || !activeRoom?.exists || !syncSince) return; + const refresh = useCallback(async () => { + if (!token) return; try { - const sync = await syncMatrixRoom(token, activeRoom.key, syncSince); - const incomingMessages = sync.messages || []; - setSyncSince(sync.nextBatch || syncSince); - setMembers((current) => sync.members || current); - setMessages((current) => { - let next = mergeMessages(current, incomingMessages); - next = applyReplacements(next, sync.replacements); - next = applyReactions(next, sync.reactions); - next = applyRedactions(next, sync.redactions); - return next; - }); - const last = incomingMessages.at(-1); - if (last?.id) { - await markMatrixRoomRead(token, activeRoom.key, last.id); - clearRoomUnread(activeRoom.key); + setError(null); + const nextRooms = await loadRooms(); + const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0]; + if (selected?.exists) { + setActiveRoomKey(selected.key); + await loadRoom(selected.key); } - } catch { - // Polling errors are surfaced by manual refresh to avoid noisy chat usage. + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.'); + } finally { + setLoading(false); + setRefreshing(false); } - }, [activeRoom, clearRoomUnread, syncSince, token]); + }, [activeRoomKey, loadRoom, loadRooms, token]); + + useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { - void refreshAll(true); - }, [refreshAll]); - - useEffect(() => { - if (!activeRoomKey) return; - setReplyTarget(null); - setEditingMessage(null); - setMessages([]); - setMembers([]); - setSyncSince(undefined); - void loadRoomContent(activeRoomKey, true); - }, [activeRoomKey, loadRoomContent]); - - useEffect(() => { - const id = setInterval(() => void pollSync(), 5000); + if (!token || !activeRoom?.exists) return undefined; + const id = setInterval(async () => { + try { + const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current); + if (!result.messages?.length) return; + setMessages((current) => mergeMessages(current, result.messages || [])); + afterIdRef.current = result.nextId || afterIdRef.current; + await markChatRoomRead(token, activeRoom.key, afterIdRef.current); + } catch { + // Beim nächsten Intervall erneut versuchen. + } + }, 3000); return () => clearInterval(id); - }, [pollSync]); + }, [activeRoom, token]); - async function ensureActiveRoom(): Promise { - if (!token || !activeRoom) return null; - if (activeRoom.exists) return activeRoom; - - setProvisioning(true); - try { - const room = await provisionMatrixRoom(token, activeRoom); - setRooms((current) => current.map((item) => (item.key === activeRoom.key ? { ...item, ...room, exists: true } : item))); - await loadRoomContent(activeRoom.key, false); - return { ...activeRoom, ...room, exists: true }; - } catch (err) { - setError(err instanceof Error ? err.message : 'Raum konnte nicht bereitgestellt werden.'); - return null; - } finally { - setProvisioning(false); - } - } - - async function sendMessage() { - if (!token || !draft.trim()) return; - const room = await ensureActiveRoom(); - if (!room) return; - - setSending(true); - try { - const message = editingMessage - ? await editMatrixMessage(token, room.key, editingMessage.id, draft.trim()) - : await sendMatrixMessage(token, room.key, draft.trim(), replyTarget?.id); - setMessages((current) => - editingMessage - ? current.map((item) => (item.id === editingMessage.id ? { ...item, ...message, edited: true } : item)) - : mergeMessages(current, [message]) - ); - setDraft(''); - setReplyTarget(null); - setEditingMessage(null); - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gesendet werden.'); - } finally { - setSending(false); - } - } - - async function pickAttachment() { + async function selectRoom(room: ChatRoom) { if (!token) return; - const room = await ensureActiveRoom(); - if (!room) return; - - const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false }); - if (result.canceled || !result.assets[0]) return; + try { + setLoading(true); + const readyRoom = room.exists ? room : await provisionChatRoom(token, room); + setActiveRoomKey(readyRoom.key); + await loadRooms(); + await loadRoom(readyRoom.key); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.'); + } finally { + setLoading(false); + } + } + async function submitMessage() { + if (!token || !activeRoom?.exists || !draft.trim() || sending) return; + const text = draft.trim(); + setDraft(''); setSending(true); try { - const asset = result.assets[0]; - const message = await uploadMatrixAttachment(token, room.key, { - uri: asset.uri, - name: asset.name || 'Anhang', - mimeType: asset.mimeType, - }); + const message = await sendChatMessage(token, activeRoom.key, text); setMessages((current) => mergeMessages(current, [message])); - requestAnimationFrame(() => messageListRef.current?.scrollToEnd({ animated: true })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Anhang konnte nicht hochgeladen werden.'); + afterIdRef.current = Math.max(afterIdRef.current, message.id); + await markChatRoomRead(token, activeRoom.key, message.id); + } catch (nextError) { + setDraft(text); + setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.'); } finally { setSending(false); } } - async function provisionUser() { - if (!token) return; - setProvisioning(true); - try { - const identity = await provisionMatrixUser(token); - setMatrixUserId(identity.matrixUserId || ''); - await refreshAll(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Matrix-Benutzer konnte nicht erstellt werden.'); - } finally { - setProvisioning(false); - } - } - - async function createRoomFromDialog() { + async function submitRoom() { if (!token || !roomName.trim()) return; - const key = roomKey.trim() || normalizeRoomKey(roomName); - if (!key) return; - - setProvisioning(true); try { - const room = await createMatrixRoom(token, { - key, + const created = await createChatRoom(token, { + key: normalizeRoomKey(roomKey || roomName), name: roomName.trim(), topic: roomTopic.trim() || null, - type: 'room', }); - setRooms((current) => [{ ...room, group: 'Räume', exists: true }, ...current.filter((item) => item.key !== key)]); - setActiveRoomKey(key); - setDialogMode(null); + setCreateOpen(false); setRoomName(''); - setRoomTopic(''); setRoomKey(''); - } catch (err) { - setError(err instanceof Error ? err.message : 'Raum konnte nicht erstellt werden.'); - } finally { - setProvisioning(false); + setRoomTopic(''); + await loadRooms(); + await selectRoom(created); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.'); } } - async function inviteSelectedUser() { - if (!token || !activeRoom || !selectedUserId) return; - const room = await ensureActiveRoom(); - if (!room) return; - - setProvisioning(true); - try { - await inviteMatrixMember(token, room.key, selectedUserId); - setMembers(await fetchMatrixMembers(token, room.key)); - setDialogMode(null); - setSelectedUserId(''); - } catch (err) { - setError(err instanceof Error ? err.message : 'Mitglied konnte nicht eingeladen werden.'); - } finally { - setProvisioning(false); - } - } - - function openEdit(message: MatrixMessage) { - setEditingMessage(message); - setDialogText(message.body || ''); - setDialogMode('edit-message'); - } - - function confirmDelete(message: MatrixMessage) { - Alert.alert('Nachricht löschen', 'Diese Nachricht wirklich entfernen?', [ - { text: 'Abbrechen', style: 'cancel' }, - { - text: 'Löschen', - style: 'destructive', - onPress: () => { - if (!token || !activeRoom) return; - void deleteMatrixMessage(token, activeRoom.key, message.id) - .then(() => setMessages((current) => current.filter((item) => item.id !== message.id))) - .catch((err) => setError(err instanceof Error ? err.message : 'Nachricht konnte nicht gelöscht werden.')); - }, - }, - ]); - } - - function confirmRemoveMember(member: MatrixMember) { - Alert.alert('Mitglied entfernen', `${member.displayName || member.matrixUserId} aus dem Raum entfernen?`, [ - { text: 'Abbrechen', style: 'cancel' }, - { - text: 'Entfernen', - style: 'destructive', - onPress: () => { - if (!token || !activeRoom) return; - void removeMatrixMember(token, activeRoom.key, member.matrixUserId) - .then(() => setMembers((current) => current.filter((item) => item.matrixUserId !== member.matrixUserId))) - .catch((err) => setError(err instanceof Error ? err.message : 'Mitglied konnte nicht entfernt werden.')); - }, - }, - ]); - } - - async function addReaction(message: MatrixMessage, key: string) { - if (!token || !activeRoom) return; - try { - await reactToMatrixMessage(token, activeRoom.key, message.id, key); - await pollSync(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Reaktion konnte nicht gesendet werden.'); - } - } - - async function syncMembersNow() { - if (!token || !activeRoom) return; - setProvisioning(true); - try { - await syncMatrixMembers(token, activeRoom.key); - setMembers(await fetchMatrixMembers(token, activeRoom.key)); - } catch (err) { - setError(err instanceof Error ? err.message : 'Mitglieder konnten nicht synchronisiert werden.'); - } finally { - setProvisioning(false); - } - } - - function renderRoom({ item }: { item: MatrixRoom }) { - const active = item.key === activeRoomKey; + function renderRoom({ item }: { item: ChatRoom }) { + const active = item.key === activeRoom?.key; return ( - setActiveRoomKey(item.key)}> - {item.group || 'Raum'} - - {item.name || item.key} - - - {!item.exists ? bereitstellen : null} - {item.unread ? {item.mentions ? `@${item.mentions}` : item.unread} : null} - + void selectRoom(item)}> + {item.group || 'Räume'} + {item.name} + {item.unread ? {item.unread} : null} ); } - function renderMessage({ item }: { item: MatrixMessage }) { - const own = Boolean(item.own || item.sender === matrixUserId); - const reply = item.replyToEventId ? messages.find((message) => message.id === item.replyToEventId) : null; - + function renderMessage({ item }: { item: ChatMessage }) { return ( - - - - - {own ? 'Du' : item.senderDisplayName || item.sender} - - {messageTime(item.timestamp)} - - {reply ? ( - - - {reply.senderDisplayName || reply.sender}: {messagePreview(reply)} - - - ) : null} - {item.body ? {item.body} : null} - {item.attachment ? ( - - - {item.attachment.fileName || 'Anhang'} - - - {item.attachment.mimeType || 'Datei'} {item.attachment.size ? `· ${Math.round(item.attachment.size / 1024)} KB` : ''} - - - ) : null} - {item.edited ? bearbeitet : null} - {item.reactions?.length ? ( - - {item.reactions.map((reaction) => ( - - {reaction.key} {reaction.count || ''} - - ))} - - ) : null} - - setReplyTarget(item)}> - Antworten - - {REACTION_PRESETS.map((reaction) => ( - addReaction(item, reaction)}> - {reaction} - - ))} - {own && !item.attachment ? ( - openEdit(item)}> - Bearbeiten - - ) : null} - {own ? ( - confirmDelete(item)}> - Löschen - - ) : null} - + + + {!item.own ? {item.senderDisplayName || 'Benutzer'} : null} + {item.body} + {messageTime(item.timestamp)} ); @@ -577,222 +212,56 @@ export default function CommunicationScreen() { - Kommunikation - - {activeRoom?.name || 'Matrix Chat'} - + Chat + {activeRoom?.name || 'FEDEO Kommunikation'} - setDialogMode('create-room')}> - + - - setMembersOpen((value) => !value)}> - - + setCreateOpen(true)}>+ + setMembersOpen((value) => !value)}> {error ? {error} : null} - {status && status.enabled === false ? ( - - Matrix ist nicht aktiv - Die Kommunikation ist serverseitig noch nicht aktiviert. - - ) : null} - - {!matrixUserId ? ( - - Matrix-Benutzer fehlt - Lege deinen Matrix-Zugang an, um Räume nutzen zu können. - - {provisioning ? 'Wird erstellt...' : 'Benutzer erstellen'} - - - ) : null} - - - item.key} - renderItem={renderRoom} - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.roomList} - /> + + item.key} renderItem={renderRoom} + showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} /> {membersOpen ? ( - - Mitglieder · {members.length} - - - Sync - - setDialogMode('invite-member')}> - Einladen - - - - - {members.map((member) => ( - confirmRemoveMember(member)}> - {member.displayName || member.matrixUserId} - - ))} - - - ) : null} - - {!activeRoom?.exists ? ( - - Raum noch nicht bereitgestellt - Beim Öffnen wird der Matrix-Raum inklusive Einladungen angelegt. - - {provisioning ? 'Wird bereitgestellt...' : 'Raum bereitstellen'} - + Teilnehmer · {members.length} + {members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')} ) : null} {loading ? ( - - - Nachrichten werden geladen... - + Nachrichten werden geladen … ) : ( - item.id} - renderItem={renderMessage} + String(item.id)} renderItem={renderMessage} contentContainerStyle={styles.messageList} - refreshControl={ - { - setRefreshing(true); - void refreshAll(false); - }} - /> - } + refreshControl={ { setRefreshing(true); void refresh(); }} />} ListEmptyComponent={Noch keine Nachrichten in diesem Raum.} - onContentSizeChange={() => messageListRef.current?.scrollToEnd({ animated: true })} - /> + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} /> )} - {(replyTarget || editingMessage) && !dialogMode ? ( - - - {editingMessage ? 'Bearbeiten' : 'Antwort auf'} - - {messagePreview(editingMessage || replyTarget!)} - - - { - setReplyTarget(null); - setEditingMessage(null); - setDraft(''); - }}> - Abbrechen - - - ) : null} - - - + - - - - {sending ? '...' : 'Senden'} + + void submitMessage()}> + {sending ? '…' : 'Senden'} - setDialogMode(null)}> - - - {dialogMode === 'create-room' ? ( - <> - Raum erstellen - - - - - setDialogMode(null)}> - Abbrechen - - - Erstellen - - - - ) : null} - - {dialogMode === 'edit-message' ? ( - <> - Nachricht bearbeiten - - - setDialogMode(null)}> - Abbrechen - - { - setDraft(dialogText); - setDialogMode(null); - }}> - Übernehmen - - - - ) : null} - - {dialogMode === 'invite-member' ? ( - <> - Mitglied einladen - - {inviteCandidates.map((user) => ( - setSelectedUserId(user.userId)}> - {user.displayName || user.email || user.userId} - {user.matrixUserId} - - ))} - {!inviteCandidates.length ? Keine weiteren Benutzer verfügbar. : null} - - - setDialogMode(null)}> - Abbrechen - - - Einladen - - - - ) : null} + setCreateOpen(false)}> + + Chatraum erstellen + + + + + setCreateOpen(false)}>Abbrechen + void submitRoom()}>Erstellen - + ); @@ -800,134 +269,26 @@ export default function CommunicationScreen() { const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: '#f9fafb' }, - header: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: '#ffffff', - borderBottomColor: '#e5e7eb', - borderBottomWidth: 1, - }, - headerMain: { flex: 1, minWidth: 0 }, - title: { color: '#111827', fontSize: 20, fontWeight: '800' }, - subtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 }, - headerButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: '#d1d5db', - backgroundColor: '#ffffff', - }, - headerButtonText: { color: '#111827', fontSize: 20, fontWeight: '700' }, - error: { margin: 12, color: '#991b1b', backgroundColor: '#fee2e2', borderRadius: 8, padding: 10 }, - notice: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fed7aa', gap: 8 }, - noticeTitle: { color: '#111827', fontWeight: '700', fontSize: 15 }, - noticeText: { color: '#6b7280', fontSize: 13 }, - roomPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 10 }, - searchInput: { - marginHorizontal: 16, - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 9, - fontSize: 14, - backgroundColor: '#ffffff', - }, - roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, - roomChip: { width: 150, padding: 10, borderRadius: 10, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#ffffff', gap: 3 }, - roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, - roomGroup: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' }, - roomName: { color: '#111827', fontSize: 14, fontWeight: '700' }, - roomTextActive: { color: '#2f6f25' }, - roomMetaRow: { flexDirection: 'row', gap: 5, minHeight: 18 }, - roomBadge: { color: '#ffffff', backgroundColor: PRIMARY, borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 }, - roomBadgeMuted: { color: '#6b7280', backgroundColor: '#f3f4f6', borderRadius: 9, overflow: 'hidden', paddingHorizontal: 6, fontSize: 11 }, - membersPanel: { backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', padding: 12, gap: 8 }, - membersHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - membersTitle: { color: '#111827', fontWeight: '700' }, - membersActions: { flexDirection: 'row', gap: 16 }, - memberList: { gap: 8 }, - memberPill: { backgroundColor: '#f3f4f6', paddingHorizontal: 10, paddingVertical: 7, borderRadius: 16 }, - memberName: { color: '#374151', fontSize: 12 }, - provisionPanel: { margin: 12, padding: 12, borderRadius: 10, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#e5e7eb', gap: 8 }, - loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, - loadingText: { color: '#6b7280' }, - messageList: { padding: 12, gap: 10, flexGrow: 1 }, - emptyText: { color: '#6b7280', textAlign: 'center', padding: 18 }, - messageRow: { alignItems: 'flex-start' }, - messageRowOwn: { alignItems: 'flex-end' }, - messageBubble: { - maxWidth: '88%', - minWidth: 120, - backgroundColor: '#ffffff', - borderRadius: 12, - borderWidth: 1, - borderColor: '#e5e7eb', - padding: 10, - gap: 6, - }, - messageBubbleOwn: { backgroundColor: '#3f8f32', borderColor: '#3f8f32' }, - messageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, - messageSender: { flex: 1, color: '#111827', fontSize: 12, fontWeight: '700' }, - messageSenderOwn: { color: '#ffffff' }, - messageTime: { color: '#6b7280', fontSize: 11 }, - messageTimeOwn: { color: '#dff4d9' }, - messageBody: { color: '#111827', fontSize: 15, lineHeight: 21 }, - messageBodyOwn: { color: '#ffffff' }, - replyBox: { backgroundColor: '#f3f4f6', borderRadius: 8, borderLeftWidth: 3, borderLeftColor: PRIMARY, padding: 7 }, - replyBoxOwn: { backgroundColor: '#327628', borderLeftColor: '#dff4d9' }, - replyText: { color: '#4b5563', fontSize: 12 }, - replyTextOwn: { color: '#dff4d9' }, - attachmentBox: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 8, gap: 2 }, - attachmentBoxOwn: { borderColor: '#dff4d9' }, - attachmentTitle: { color: '#111827', fontWeight: '700' }, - attachmentMeta: { color: '#6b7280', fontSize: 12 }, - editedText: { color: '#6b7280', fontSize: 11 }, - reactionRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 5 }, - reactionPill: { backgroundColor: '#f3f4f6', borderRadius: 12, overflow: 'hidden', paddingHorizontal: 7, paddingVertical: 2, fontSize: 12 }, - reactionPillOwn: { backgroundColor: '#dff4d9' }, - messageActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 2 }, - actionText: { color: '#3f8f32', fontSize: 12, fontWeight: '700' }, - actionTextOwn: { color: '#ffffff' }, - actionTextDestructive: { color: '#fee2e2' }, - composerContext: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: '#ffffff', - borderTopWidth: 1, - borderTopColor: '#e5e7eb', - }, - composerContextMain: { flex: 1, minWidth: 0 }, - composerContextLabel: { color: '#6b7280', fontSize: 11, textTransform: 'uppercase' }, - composerContextText: { color: '#111827', fontSize: 13, fontWeight: '600' }, - composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 10, backgroundColor: '#ffffff', borderTopWidth: 1, borderTopColor: '#e5e7eb' }, - attachButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f3f4f6' }, - attachButtonText: { color: '#111827', fontSize: 22, fontWeight: '700' }, - composerInput: { flex: 1, maxHeight: 110, borderRadius: 18, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 }, - sendButton: { minWidth: 70, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: PRIMARY, paddingHorizontal: 12 }, - sendButtonDisabled: { opacity: 0.45 }, - sendButtonText: { color: '#ffffff', fontWeight: '800' }, - primaryButton: { alignSelf: 'flex-start', backgroundColor: PRIMARY, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 9 }, - primaryButtonText: { color: '#ffffff', fontWeight: '800' }, - linkText: { color: '#3f8f32', fontWeight: '800' }, - modalBackdrop: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', alignItems: 'center', justifyContent: 'center', padding: 18 }, - modalCard: { width: '100%', maxWidth: 420, borderRadius: 12, backgroundColor: '#ffffff', padding: 16, gap: 12 }, - modalTitle: { color: '#111827', fontSize: 18, fontWeight: '800' }, - modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 }, - modalTextarea: { minHeight: 110, textAlignVertical: 'top' }, - modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 14 }, - inviteList: { maxHeight: 280 }, - inviteRow: { padding: 10, borderRadius: 9, borderWidth: 1, borderColor: '#e5e7eb', marginBottom: 8 }, - inviteRowActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, - inviteName: { color: '#111827', fontWeight: '700' }, - inviteMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 }, + header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 }, + headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' }, + headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' }, + roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, + searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 }, + roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 }, + roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' }, + roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' }, + roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 }, + membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 }, + loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' }, + messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' }, + messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' }, + messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' }, + messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' }, + composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' }, + composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 }, + sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' }, + modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' }, + modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' }, }); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 87de4a4..1f5ad58 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -259,27 +259,12 @@ export function isAuthenticationError(error: unknown): boolean { return error instanceof ApiError && error.status === 401; } -export type MatrixStatus = { - enabled?: boolean; - ready?: boolean; - configured?: boolean; - homeserverUrl?: string | null; - [key: string]: unknown; -}; - -export type MatrixIdentity = { - matrixUserId: string; - displayName?: string | null; -}; - -export type MatrixRoom = { +export type ChatRoom = { key: string; name: string; topic?: string | null; type?: 'room' | 'project' | 'direct' | string; group?: string; - roomId?: string | null; - alias?: string | null; exists?: boolean; projectId?: number; projectNumber?: string | null; @@ -294,66 +279,31 @@ export type MatrixRoom = { [key: string]: unknown; }; -export type MatrixAttachment = { - fileName?: string | null; - mimeType?: string | null; - size?: number | null; - mxcUri?: string | null; - previewUrl?: string | null; - downloadUrl?: string | null; -}; - -export type MatrixReaction = { - key: string; - count?: number; - own?: boolean; - senders?: string[]; - [key: string]: unknown; -}; - -export type MatrixMessage = { - id: string; +export type ChatMessage = { + id: number; sender: string; senderDisplayName?: string | null; body?: string | null; timestamp?: string | number | null; own?: boolean; - edited?: boolean; - redacted?: boolean; - msgtype?: string; - attachment?: MatrixAttachment | null; - replyToEventId?: string | null; - reactions?: MatrixReaction[]; [key: string]: unknown; }; -export type MatrixMember = { - matrixUserId: string; - displayName?: string | null; - avatarUrl?: string | null; - membership?: string; - [key: string]: unknown; -}; - -export type MatrixUser = { +export type ChatMember = { userId: string; - matrixUserId: string; displayName?: string | null; email?: string | null; + own?: boolean; [key: string]: unknown; }; -export type MatrixSyncResponse = { - nextBatch?: string; - messages?: MatrixMessage[]; - replacements?: MatrixMessage[]; - reactions?: (MatrixReaction & { targetEventId?: string })[]; - redactions?: { redacts?: string; eventId?: string; targetEventId?: string }[]; - members?: MatrixMember[]; +export type ChatSyncResponse = { + nextId?: number; + messages?: ChatMessage[]; [key: string]: unknown; }; -export type MatrixUnreadCounts = Record; +export type ChatUnreadCounts = Record; function buildUrl(path: string): string { if (path.startsWith('http://') || path.startsWith('https://')) { @@ -429,77 +379,26 @@ export async function apiRequest(path: string, options: RequestOptions = {}): return payload as T; } -async function apiFormRequest(path: string, token: string, formData: FormData): Promise { - const { signal, cleanup } = createTimeoutSignal(); - let response: Response; - - try { - response = await fetch(buildUrl(path), { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - }, - body: formData, - signal, - }); - } catch (error) { - if (isAbortError(error)) { - throw new Error(`Zeitüberschreitung beim Hochladen zum FEDEO-Server (${path}).`); - } - throw error; - } finally { - cleanup(); - } - - const payload = await parseJson(response); - - if (!response.ok) { - const message = - (payload as { message?: string; error?: string } | null)?.message || - (payload as { message?: string; error?: string } | null)?.error || - `Request failed (${response.status}) for ${path}`; - throw new ApiError(message, response.status); - } - - return payload as T; -} - -function matrixRoomPath(roomKey: string, suffix = ''): string { - return `/api/communication/matrix/rooms/${encodeURIComponent(roomKey)}${suffix}`; +function chatRoomPath(roomKey: string, suffix = ''): string { + return `/api/communication/chat/rooms/${encodeURIComponent(roomKey)}${suffix}`; } export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> { return apiRequest<{ status: string; [key: string]: unknown }>('/health'); } -export async function fetchMatrixStatus(token: string): Promise { - return apiRequest('/api/communication/matrix/status', { token }); -} - -export async function fetchMatrixIdentity(token: string): Promise { - return apiRequest('/api/communication/matrix/me', { token }); -} - -export async function provisionMatrixUser(token: string): Promise { - return apiRequest('/api/communication/matrix/me/provision', { - method: 'POST', - token, - }); -} - -export async function fetchMatrixRooms(token: string): Promise { +export async function fetchChatRooms(token: string): Promise { const [rooms, projectRooms, directRooms, unread] = await Promise.all([ - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/rooms', { token }), - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/project-rooms', { token }), - apiRequest<{ rooms?: MatrixRoom[] }>('/api/communication/matrix/direct-rooms', { token }), - apiRequest<{ rooms?: Record }>('/api/communication/matrix/unread', { + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/rooms', { token }), + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/project-rooms', { token }), + apiRequest<{ rooms?: ChatRoom[] }>('/api/communication/chat/direct-rooms', { token }), + apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token, }), ]); const unreadByRoom = unread.rooms || {}; - const decorate = (room: MatrixRoom, group: string): MatrixRoom => ({ + const decorate = (room: ChatRoom, group: string): ChatRoom => ({ ...room, group, unread: unreadByRoom[room.key]?.count || 0, @@ -513,165 +412,70 @@ export async function fetchMatrixRooms(token: string): Promise { ]; } -export async function fetchMatrixUnreadCounts(token: string): Promise { - const response = await apiRequest<{ rooms?: MatrixUnreadCounts }>('/api/communication/matrix/unread', { token }); +export async function fetchChatUnreadCounts(token: string): Promise { + const response = await apiRequest<{ rooms?: ChatUnreadCounts }>('/api/communication/chat/unread', { token }); return response.rooms || {}; } -export async function fetchMatrixUsers(token: string): Promise { - const response = await apiRequest<{ users?: MatrixUser[] }>('/api/communication/matrix/users', { token }); - return response.users || []; -} - -export async function createMatrixRoom( +export async function createChatRoom( token: string, payload: { key: string; name: string; topic?: string | null; type?: string } -): Promise { - return apiRequest('/api/communication/matrix/rooms', { +): Promise { + return apiRequest('/api/communication/chat/rooms', { method: 'POST', token, body: payload, }); } -export async function provisionMatrixRoom(token: string, room: MatrixRoom): Promise { - if (room.provisionEndpoint) { - return apiRequest(room.provisionEndpoint, { method: 'POST', token }); - } - +export async function provisionChatRoom(token: string, room: ChatRoom): Promise { if (room.type === 'project' && room.projectId) { - return apiRequest(`/api/communication/matrix/project-rooms/${room.projectId}/provision`, { + return apiRequest(`/api/communication/chat/project-rooms/${room.projectId}/provision`, { method: 'POST', token, }); } if (room.type === 'direct' && room.userId) { - return apiRequest(`/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`, { + return apiRequest(`/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`, { method: 'POST', token, }); } - return apiRequest(matrixRoomPath(room.key, '/provision'), { - method: 'POST', - token, - body: { - key: room.key, - name: room.name, - topic: room.topic, - type: room.type || 'room', - entityType: room.entityType, - entityId: room.entityId, - entityUuid: room.entityUuid, - }, - }); + return room; } -export async function fetchMatrixMessages(token: string, roomKey: string): Promise { - const response = await apiRequest<{ messages?: MatrixMessage[] }>(matrixRoomPath(roomKey, '/messages'), { token }); +export async function fetchChatMessages(token: string, roomKey: string): Promise { + const response = await apiRequest<{ messages?: ChatMessage[] }>(chatRoomPath(roomKey, '/messages'), { token }); return response.messages || []; } -export async function syncMatrixRoom( - token: string, - roomKey: string, - since?: string, - initial = false -): Promise { - const query = new URLSearchParams(); - if (since) query.set('since', since); - if (initial) query.set('initial', '1'); - const suffix = query.toString() ? `/sync?${query.toString()}` : '/sync'; - return apiRequest(matrixRoomPath(roomKey, suffix), { token }); +export async function syncChatRoom(token: string, roomKey: string, afterId = 0): Promise { + return apiRequest(chatRoomPath(roomKey, `/sync?afterId=${afterId}`), { token }); } -export async function fetchMatrixMembers(token: string, roomKey: string): Promise { - const response = await apiRequest<{ members?: MatrixMember[] }>(matrixRoomPath(roomKey, '/members'), { token }); +export async function fetchChatMembers(token: string, roomKey: string): Promise { + const response = await apiRequest<{ members?: ChatMember[] }>(chatRoomPath(roomKey, '/members'), { token }); return response.members || []; } -export async function sendMatrixMessage( - token: string, - roomKey: string, - text: string, - replyToEventId?: string | null -): Promise { - return apiRequest(matrixRoomPath(roomKey, '/messages'), { +export async function sendChatMessage(token: string, roomKey: string, text: string): Promise { + return apiRequest(chatRoomPath(roomKey, '/messages'), { method: 'POST', token, - body: { text, replyToEventId }, - }); -} - -export async function editMatrixMessage(token: string, roomKey: string, eventId: string, text: string): Promise { - return apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), { - method: 'PUT', - token, body: { text }, }); } -export async function deleteMatrixMessage(token: string, roomKey: string, eventId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}`), { - method: 'DELETE', - token, - }); -} - -export async function reactToMatrixMessage(token: string, roomKey: string, eventId: string, key: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/messages/${encodeURIComponent(eventId)}/reactions`), { +export async function markChatRoomRead(token: string, roomKey: string, messageId?: number): Promise { + await apiRequest(chatRoomPath(roomKey, '/read'), { method: 'POST', token, - body: { key }, + body: { messageId }, }); } -export async function markMatrixRoomRead(token: string, roomKey: string, eventId?: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/read'), { - method: 'POST', - token, - body: { eventId }, - }); -} - -export async function syncMatrixMembers(token: string, roomKey: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/members/sync'), { - method: 'POST', - token, - }); -} - -export async function inviteMatrixMember(token: string, roomKey: string, userId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, '/members/invite'), { - method: 'POST', - token, - body: { userId }, - }); -} - -export async function removeMatrixMember(token: string, roomKey: string, matrixUserId: string): Promise { - await apiRequest(matrixRoomPath(roomKey, `/members/${encodeURIComponent(matrixUserId)}`), { - method: 'DELETE', - token, - }); -} - -export async function uploadMatrixAttachment( - token: string, - roomKey: string, - file: { uri: string; name: string; mimeType?: string | null } -): Promise { - const formData = new FormData(); - formData.append('file', { - uri: file.uri, - name: file.name, - type: file.mimeType || 'application/octet-stream', - } as unknown as Blob); - - return apiFormRequest(matrixRoomPath(roomKey, '/attachments'), token, formData); -} - export async function renderPrintLabel( token: string, context: Record, diff --git a/scripts/selfhost-setup.sh b/scripts/selfhost-setup.sh index aacf3fd..eb44c12 100755 --- a/scripts/selfhost-setup.sh +++ b/scripts/selfhost-setup.sh @@ -235,7 +235,7 @@ choose_mode() { echo echo "Setup-Modus" - echo " 1) einfach - Domain, Admin, lokale Datenbank, MinIO, Matrix" + echo " 1) einfach - Domain, Admin, lokale Datenbank und MinIO" echo " 2) advanced - zusätzlich SMTP, externe Schlüssel und optionale Dienste" echo @@ -266,12 +266,10 @@ FEDEO Selfhost Setup Dieses Script führt dich durch die lokale Betriebsstruktur: $ROOT_DIR/ - $(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO, Matrix und Monitoring + $(basename "$COMPOSE_FILE") Docker Stack für FEDEO, Traefik, PostgreSQL, MinIO und Monitoring .env Zielkonfiguration, wird von diesem Script geschrieben postgres/ persistente FEDEO-Datenbank minio/ lokaler S3-kompatibler Dateispeicher - matrix/postgres/ persistente Synapse-Datenbank - matrix/synapse/ generierte Synapse-Konfiguration und Matrix-Daten traefik/letsencrypt/ Let's-Encrypt-Zertifikate traefik/logs/ Traefik-Logs @@ -279,11 +277,6 @@ Dieses Script führt dich durch die lokale Betriebsstruktur: https://DOMAIN/ FEDEO Frontend https://DOMAIN/backend FEDEO API - https://DOMAIN/_matrix Matrix Homeserver - https://DOMAIN/.well-known Matrix Discovery - https://DOMAIN/livekit/sfu LiveKit - https://DOMAIN/livekit/jwt LiveKit JWT-Service - https://DOMAIN/element Element Web EOF } @@ -303,32 +296,28 @@ write_env() { local admin_last_name="${12}" local tenant_name="${13}" local tenant_short="${14}" - local matrix_db_password="${15}" - local matrix_turn_secret="${16}" - local matrix_registration_secret="${17}" - local livekit_secret="${18}" - local mailer_host="${19}" - local mailer_port="${20}" - local mailer_ssl="${21}" - local mailer_user="${22}" - local mailer_pass="${23}" - local mailer_from="${24}" - local web_push_public="${25}" - local web_push_private="${26}" - local pdf_license="${27}" - local openai_key="${28}" - local stirling_key="${29}" - local gocardless_secret_id="${30}" - local gocardless_secret_key="${31}" - local dokubox_host="${32}" - local dokubox_port="${33}" - local dokubox_secure="${34}" - local dokubox_user="${35}" - local dokubox_password="${36}" - local central_services_enabled="${37}" - local central_services_url="${38}" - local central_instance_id="${39}" - local central_instance_secret="${40}" + local mailer_host="${15}" + local mailer_port="${16}" + local mailer_ssl="${17}" + local mailer_user="${18}" + local mailer_pass="${19}" + local mailer_from="${20}" + local web_push_public="${21}" + local web_push_private="${22}" + local pdf_license="${23}" + local openai_key="${24}" + local stirling_key="${25}" + local gocardless_secret_id="${26}" + local gocardless_secret_key="${27}" + local dokubox_host="${28}" + local dokubox_port="${29}" + local dokubox_secure="${30}" + local dokubox_user="${31}" + local dokubox_password="${32}" + local central_services_enabled="${33}" + local central_services_url="${34}" + local central_instance_id="${35}" + local central_instance_secret="${36}" cat >"$ENV_FILE" <Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/impressum.vue b/website/app/pages/impressum.vue index e26a7d0..2891098 100644 --- a/website/app/pages/impressum.vue +++ b/website/app/pages/impressum.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/index.vue b/website/app/pages/index.vue index 19548cb..ad72952 100644 --- a/website/app/pages/index.vue +++ b/website/app/pages/index.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt @@ -167,42 +167,42 @@ -
+

Tief integriert

-

Matrix als Kommunikationsschicht für FEDEO.

+

Chat direkt in FEDEO.

- FEDEO verbindet Chat, Räume, Anrufe und Videokonferenzen mit Projekten, Vorgängen, Kontakten und Berechtigungen. Matrix läuft dabei nicht daneben, sondern wird durch FEDEO provisioniert, verknüpft und betrieben. + FEDEO verbindet Nachrichten und Räume direkt mit Projekten, Kontakten und Berechtigungen. Identitäten, Zugriffe und Gesprächsverläufe bleiben dabei vollständig im FEDEO-Kontext.

-
-
-
+
+
+
{{ item.tag }}

{{ item.title }}

{{ item.description }}

-
+
FEDEO Web & App
FEDEO Backend
SSO, Rechte, Objektkontext
-
Matrix Homeserver
Synapse, Räume, Events
+
FEDEO Chat
Räume, Nachrichten, Lesestatus
-
FEDEO Kommunikation
Räume, Gäste, Föderation
+
FEDEO Datenbank
Mandantengetrennte Speicherung
-

Föderation

-

FEDEO-Instanzen können kontrolliert miteinander sprechen.

+

Klare Zuständigkeit

+

Kommunikation folgt den FEDEO-Berechtigungen.

- Für sensible Unternehmenskommunikation bleibt die Föderation standardmäßig steuerbar: geschlossen für interne Installationen, per Allowlist für Partner und Kunden oder bewusst geöffnet für öffentliche Matrix-Szenarien. + Allgemeine, projektbezogene und direkte Chats werden mandantengetrennt gespeichert. Nutzer sehen nur Räume, für die ihnen FEDEO einen Zugriff zuweist.

@@ -257,10 +257,10 @@ const features = [ { tag: 'Büro', title: 'Aufgaben und Kommunikation', - description: 'Aufgaben, interne Nachrichten und Matrix-Räume laufen dort zusammen, wo die Arbeit entsteht.', + description: 'Aufgaben und interne Nachrichten laufen dort zusammen, wo die Arbeit entsteht.', details: [ 'Aufgaben mit Bezug zu Projekten, Vorgängen und Teams', - 'Matrix-Räume für projektnahe Abstimmung', + 'Chaträume für projektnahe Abstimmung', 'Benachrichtigungen und Lesestatus für laufende Arbeit' ] }, @@ -316,31 +316,31 @@ const features = [ } ] -const matrixStack = [ +const chatStack = [ { tag: 'Chat', title: 'Projekt- und Vorgangsräume', - description: 'Matrix-Räume werden aus FEDEO-Kontexten wie Projekten, Tickets, Teams und Kontakten heraus genutzt.' + description: 'Chaträume werden aus FEDEO-Kontexten wie Projekten, Teams und Kontakten heraus genutzt.' }, { tag: 'Identität', title: 'FEDEO bleibt führend', - description: 'Nutzer, Rollen und Raumzugriffe werden aus FEDEO heraus provisioniert und bei Änderungen synchronisiert.' + description: 'Nutzer, Rollen und Raumzugriffe werden direkt über FEDEO verwaltet.' }, { tag: 'Kommunikation', - title: 'Chat, Gäste und Räume', - description: 'Teamräume, externe Gäste und projektnahe Abstimmung bleiben direkt mit FEDEO-Kontexten verbunden.' + title: 'Nachrichten und Räume', + description: 'Allgemeine, direkte und projektnahe Abstimmung bleibt direkt mit FEDEO-Kontexten verbunden.' }, { tag: 'Betrieb', - title: 'Selfhost-ready Stack', - description: 'Synapse, PostgreSQL, Redis, .well-known, Element und TURN/STUN sind im Selfhost-Stack vorbereitet.' + title: 'Selfhost-ready', + description: 'Chatdaten werden mit der bestehenden FEDEO-Datenbank und ohne zusätzliche Kommunikationsdienste betrieben.' }, { - tag: 'Föderation', - title: 'Instanzübergreifende Zusammenarbeit', - description: 'Mehrere FEDEO- oder Matrix-Instanzen können über freigegebene Domains föderieren, ohne die lokale Kontrolle über Nutzer und Räume aufzugeben.' + tag: 'Sicherheit', + title: 'Mandantengetrennte Kommunikation', + description: 'Teilnehmerrechte und Lesestatus werden serverseitig geprüft und innerhalb der jeweiligen FEDEO-Instanz gespeichert.' } ] diff --git a/website/app/pages/kontakt.vue b/website/app/pages/kontakt.vue index 0d6ac3d..7abbfc0 100644 --- a/website/app/pages/kontakt.vue +++ b/website/app/pages/kontakt.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt diff --git a/website/app/pages/zielgruppen.vue b/website/app/pages/zielgruppen.vue index 794bfbd..183ac94 100644 --- a/website/app/pages/zielgruppen.vue +++ b/website/app/pages/zielgruppen.vue @@ -10,7 +10,7 @@ Funktionen Open Source Selfhost - Matrix + Chat Kontakt @@ -112,7 +112,7 @@ const audiences = [ points: [ 'Vorgänge mit Historie, Dokumentation und Zuständigkeit', 'Rollen und Berechtigungen für unterschiedliche Teams', - 'Nachvollziehbare Kommunikation über Matrix-Räume' + 'Nachvollziehbare Kommunikation in projektbezogenen Chats' ] }, { @@ -122,7 +122,7 @@ const audiences = [ points: [ 'Offener Code und nachvollziehbare Architektur', 'Docker-basierter Selfhost-Stack', - 'Matrix-Föderation und Integrationen kontrollierbar betreiben' + 'Mandanteninterne Kommunikation kontrolliert betreiben' ] }, {