From 5b3445c2dc23a4e4ed25a8afe66effda5d05531d Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 19 May 2026 08:27:39 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Projektr=C3=A4ume=20und=20Direktnac?= =?UTF-8?q?hrichten=20integrieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/matrix.service.ts | 43 +++++- backend/src/routes/communication.ts | 201 +++++++++++++++++++++++++- frontend/pages/communication/chat.vue | 122 ++++++++++++---- 3 files changed, 338 insertions(+), 28 deletions(-) diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index b0af503..2d562e1 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -56,6 +56,7 @@ type MatrixTenantRoomOptions = { entityType?: string | null entityId?: number | null entityUuid?: string | null + inviteUserIds?: string[] } type MatrixCachedValue = { @@ -247,6 +248,7 @@ export function matrixService(server: FastifyInstance) { entityType: options.entityType || null, entityId: options.entityId || null, entityUuid: options.entityUuid || null, + inviteUserIds: options.inviteUserIds || [], } } @@ -885,6 +887,7 @@ export function matrixService(server: FastifyInstance) { 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) { @@ -902,6 +905,8 @@ export function matrixService(server: FastifyInstance) { invitedUserId: userAccount.matrixUserId, } + await inviteUsersToRoom(existing.roomId, invitedMatrixUserIds) + matrixTenantRoomCache.set(cacheKey, { exists: true, cachedUntil: Date.now() + 30 * 60 * 1000, @@ -923,7 +928,7 @@ export function matrixService(server: FastifyInstance) { preset: "private_chat", visibility: "private", room_alias_name: tenantRoomAliasLocalpart(tenant, key), - invite: [userAccount.matrixUserId], + invite: Array.from(new Set([userAccount.matrixUserId, ...invitedMatrixUserIds])), initial_state: [ { type: "m.room.history_visibility", @@ -994,6 +999,42 @@ export function matrixService(server: FastifyInstance) { 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 ensureCurrentUserJoinedRoom = async ( userId: string, tenantId: number | null, diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index f15424a..512a376 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,6 +1,7 @@ +import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" -import { and, eq, ne } from "drizzle-orm" -import { authTenantUsers, authUsers } from "../../db/schema" +import { and, eq, inArray, ne } from "drizzle-orm" +import { authProfiles, authTenantUsers, authUsers, projects } from "../../db/schema" import { matrixService } from "../modules/matrix.service" import { NotificationService, UserDirectory } from "../modules/notification.service" @@ -47,6 +48,22 @@ export default async function communicationRoutes(server: FastifyInstance) { } } + 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 callModeFromRequest = (req: any): "audio" | "video" => { const body = (req.body || {}) as { mode?: string } return body.mode === "audio" ? "audio" : "video" @@ -131,6 +148,186 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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), + 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, + } + }) + } + } 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" }) + } + + 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") + } + }) + server.post("/communication/matrix/rooms", async (req, reply) => { try { return await matrix.provisionTenantRoom( diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index c5c063f..8ad0ae6 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -7,6 +7,8 @@ const { $api } = useNuxtApp() const status = ref(null) const identity = ref(null) const matrixRooms = ref([]) +const matrixProjectRooms = ref([]) +const matrixDirectRooms = ref([]) const activeRoomKey = ref("allgemein") const matrixMessages = ref([]) const matrixMembers = ref([]) @@ -35,6 +37,7 @@ const roomCreateForm = ref({ }) const loading = ref(false) const roomProvisioning = ref(false) +const roomProvisioningKey = ref("") const roomCreating = ref(false) const roomMembersSyncing = ref(false) const matrixMessagesLoading = ref(false) @@ -54,7 +57,7 @@ const canUseMatrixChat = computed(() => ) const activeRoom = computed(() => - matrixRooms.value.find((room) => room.key === activeRoomKey.value) || { + rooms.value.find((room) => room.key === activeRoomKey.value) || { key: activeRoomKey.value, name: activeRoomKey.value, description: "Mandantenweiter Austausch", @@ -109,26 +112,34 @@ const roomCreateKeyPreview = computed(() => normalizeRoomKey(roomCreateForm.value.key || roomCreateForm.value.name) ) -const rooms = computed(() => [ - ...matrixRooms.value.map((room) => ({ +const rooms = computed(() => { + const baseRooms = matrixRooms.value + .filter((room) => !["project", "direct"].includes(room.type)) + .map((room) => ({ + ...room, + group: "Räume", + icon: "i-heroicons-chat-bubble-left-right", + description: room.alias || room.roomId || "Mandantenweiter Austausch" + })) + + const projectRooms = matrixProjectRooms.value.map((room) => ({ ...room, - description: room.alias || room.roomId || "Mandantenweiter Austausch" - })), - { - key: "projects", - name: "Projekt-Chats", - description: "Nächste Ausbaustufe", - exists: false, - disabled: true - }, - { - key: "direct", - name: "Direktnachrichten", - description: "Nächste Ausbaustufe", - exists: false, - disabled: true - } -]) + group: "Projekte", + icon: "i-heroicons-briefcase", + description: room.projectNumber || room.topic || "Projektkommunikation", + provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision` + })) + + const directRooms = matrixDirectRooms.value.map((room) => ({ + ...room, + group: "Direkt", + icon: "i-heroicons-user-circle", + description: room.email || room.topic || "Direktnachricht", + provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision` + })) + + return [...baseRooms, ...projectRooms, ...directRooms] +}) const normalizeRoomKey = (value) => { const normalized = String(value || "") @@ -149,12 +160,61 @@ const normalizeRoomKey = (value) => { const setActiveRoom = async (room) => { if (room.disabled || room.key === activeRoomKey.value) return + if (!room.exists && room.provisionEndpoint) { + await provisionRoomFromList(room) + return + } + activeRoomKey.value = room.key matrixMessages.value = [] matrixMembers.value = [] await loadRoomChat({ includeMembers: true }) } +const mergeRoomIntoLists = (room) => { + upsertRoom({ ...room, exists: true }) + + if (room.type === "project") { + matrixProjectRooms.value = matrixProjectRooms.value.map((item) => + item.key === room.key ? { ...item, ...room, exists: true } : item + ) + } + + if (room.type === "direct") { + matrixDirectRooms.value = matrixDirectRooms.value.map((item) => + item.key === room.key ? { ...item, ...room, exists: true } : item + ) + } +} + +const provisionRoomFromList = async (room) => { + roomProvisioningKey.value = room.key + try { + const createdRoom = await $api(room.provisionEndpoint, { + method: "POST" + }) + + mergeRoomIntoLists(createdRoom) + activeRoomKey.value = createdRoom.key + matrixMessages.value = [] + matrixMembers.value = [] + + toast.add({ + title: "Chatraum ist bereit", + color: "success" + }) + + await loadRoomChat({ includeMembers: true }) + } catch (error) { + toast.add({ + title: "Chatraum konnte nicht erstellt werden", + color: "error" + }) + } finally { + roomProvisioningKey.value = "" + } +} + const upsertRoom = (room) => { const roomWasKnown = matrixRooms.value.some((item) => item.key === room.key) matrixRooms.value = roomWasKnown @@ -192,15 +252,19 @@ const scrollMessagesToBottom = async () => { const loadChatInfo = async () => { loading.value = true try { - const [statusRes, identityRes, roomsRes] = await Promise.all([ + const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes] = await Promise.all([ $api("/api/communication/matrix/status"), $api("/api/communication/matrix/me"), - $api("/api/communication/matrix/rooms") + $api("/api/communication/matrix/rooms"), + $api("/api/communication/matrix/project-rooms"), + $api("/api/communication/matrix/direct-rooms") ]) status.value = statusRes identity.value = identityRes matrixRooms.value = roomsRes.rooms || [] + matrixProjectRooms.value = projectRoomsRes.rooms || [] + matrixDirectRooms.value = directRoomsRes.rooms || [] lastUpdated.value = new Date() if (activeRoom.value?.exists && canUseMatrixChat.value) { @@ -869,14 +933,22 @@ onBeforeUnmount(() => { @click="setActiveRoom(room)" > - + {{ room.name }} {{ room.description }} + lädt + + {
-