From a671ae392d60a1fef5d89d2cb89c3bf6e433278c Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 20 May 2026 20:21:18 +0200 Subject: [PATCH] KI-AGENT: Mitgliederverwaltung und Suche im Chat umsetzen --- backend/src/modules/matrix.service.ts | 204 +++++++++++++++++++++++- backend/src/routes/communication.ts | 51 ++++++ frontend/pages/communication/chat.vue | 221 +++++++++++++++++++++++++- 3 files changed, 472 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 048f2f9..09e4974 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -47,6 +47,19 @@ type MatrixJoinedMembersResponse = { }> } +type MatrixRoomSearchResponse = { + search_categories?: { + room_events?: { + count?: number + results?: Array<{ + result?: MatrixRoomEvent & { + room_id?: string + } + }> + } + } +} + type MatrixUserSession = { accessToken: string matrixUserId: string @@ -1273,6 +1286,90 @@ export function matrixService(server: FastifyInstance) { } } + 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 sendTenantRoomMessage = async ( userId: string, tenantId: number | null, @@ -1608,13 +1705,18 @@ export function matrixService(server: FastifyInstance) { )) .where(eq(authTenantUsers.tenant_id, tenant.id)) - return rows + 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 ( @@ -1708,6 +1810,102 @@ export function matrixService(server: FastifyInstance) { } } + 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", @@ -1756,11 +1954,13 @@ export function matrixService(server: FastifyInstance) { getTenantSpaceStatus, provisionCurrentTenantSpace, listTenantRooms, + listTenantCommunicationUsers, getTenantRoomStatus, provisionTenantRoom, createAccessTokenForUser, getTenantRoomMessages, getTenantRoomMembers, + searchTenantRoomMessages, sendTenantRoomMessage, sendTenantRoomReaction, markTenantRoomRead, @@ -1768,6 +1968,8 @@ export function matrixService(server: FastifyInstance) { getMediaContent, createElementRoomSession, createLiveKitRoomSession, + inviteTenantRoomMember, + removeTenantRoomMember, syncTenantRoomMembers, getGeneralRoomMessages, getGeneralRoomMembers, diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 562ad94..bea8443 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -603,6 +603,15 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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, { @@ -713,6 +722,20 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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/matrix/rooms/:roomKey/members", async (req, reply) => { try { return await matrix.getTenantRoomMembers( @@ -725,6 +748,34 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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.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( diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 4d3f67b..5913006 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -14,12 +14,17 @@ const unreadRooms = ref({}) const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein") const matrixMessages = ref([]) const matrixMembers = ref([]) +const matrixTenantUsers = ref([]) const matrixMessageDraft = ref("") const matrixMessagesViewport = ref(null) const matrixAttachmentInput = ref(null) const matrixAttachmentObjectUrls = ref({}) const matrixReplyTarget = ref(null) const matrixDragActive = ref(false) +const memberInviteOpen = ref(false) +const memberInviteUserId = ref("") +const matrixSearchQuery = ref("") +const matrixSearchResults = ref([]) const roomCreateOpen = ref(false) const collapsedRoomGroups = ref({}) const matrixCallOpen = ref(false) @@ -49,6 +54,9 @@ const roomCreating = ref(false) const roomMembersSyncing = ref(false) const matrixMessagesLoading = ref(false) const matrixMembersLoading = ref(false) +const memberInviting = ref(false) +const memberRemovingId = ref("") +const matrixSearchLoading = ref(false) const matrixMessageSending = ref(false) const matrixAttachmentUploading = ref(false) const matrixAttachmentUploadCount = ref(0) @@ -93,6 +101,20 @@ const canSendChatInput = computed(() => Boolean(canUseMatrixChat.value && activeRoom.value?.exists && !matrixMessageSending.value && !matrixAttachmentUploading.value) ) +const availableRoomInviteUsers = computed(() => { + const roomMemberIds = new Set(matrixMembers.value.map((member) => member.matrixUserId)) + + return matrixTenantUsers.value + .filter((user) => !roomMemberIds.has(user.matrixUserId)) + .sort((first, second) => + String(first.displayName || first.email || "").localeCompare( + String(second.displayName || second.email || ""), + "de", + { sensitivity: "base" } + ) + ) +}) + const matrixCallTitle = computed(() => matrixCallMode.value === "audio" ? "Audioanruf" : "Videokonferenz" ) @@ -223,6 +245,8 @@ const setActiveRoom = async (room) => { activeRoomKey.value = room.key matrixMessages.value = [] matrixMembers.value = [] + matrixSearchResults.value = [] + memberInviteOpen.value = false await loadRoomChat({ includeMembers: true }) } @@ -253,6 +277,8 @@ const provisionRoomFromList = async (room) => { activeRoomKey.value = createdRoom.key matrixMessages.value = [] matrixMembers.value = [] + matrixSearchResults.value = [] + memberInviteOpen.value = false toast.add({ title: "Chatraum ist bereit", @@ -359,12 +385,13 @@ const markActiveRoomRead = async () => { const loadChatInfo = async () => { loading.value = true try { - const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes] = await Promise.all([ + const [statusRes, identityRes, roomsRes, projectRoomsRes, directRoomsRes, usersRes] = await Promise.all([ $api("/api/communication/matrix/status"), $api("/api/communication/matrix/me"), $api("/api/communication/matrix/rooms"), $api("/api/communication/matrix/project-rooms"), - $api("/api/communication/matrix/direct-rooms") + $api("/api/communication/matrix/direct-rooms"), + $api("/api/communication/matrix/users") ]) status.value = statusRes @@ -372,6 +399,7 @@ const loadChatInfo = async () => { matrixRooms.value = roomsRes.rooms || [] matrixProjectRooms.value = projectRoomsRes.rooms || [] matrixDirectRooms.value = directRoomsRes.rooms || [] + matrixTenantUsers.value = usersRes.users || [] lastUpdated.value = new Date() await loadUnreadCounts() @@ -442,6 +470,8 @@ const createRoom = async () => { activeRoomKey.value = room.key matrixMessages.value = [] matrixMembers.value = [] + matrixSearchResults.value = [] + memberInviteOpen.value = false roomCreateForm.value = { name: "", key: "", @@ -545,6 +575,89 @@ const syncRoomMembers = async () => { } } +const toggleMemberInvite = () => { + memberInviteOpen.value = !memberInviteOpen.value + if (memberInviteOpen.value && !memberInviteUserId.value && availableRoomInviteUsers.value[0]) { + memberInviteUserId.value = availableRoomInviteUsers.value[0].userId + } +} + +const inviteRoomMember = async () => { + if (!memberInviteUserId.value || !canUseMatrixChat.value || !activeRoom.value?.exists) return + + memberInviting.value = true + try { + const result = await $api(`${activeRoomEndpoint.value}/members/invite`, { + method: "POST", + body: { + userId: memberInviteUserId.value + } + }) + + toast.add({ + title: "Teilnehmer eingeladen", + description: result.displayName || result.email || result.matrixUserId, + color: "success" + }) + + memberInviteUserId.value = "" + memberInviteOpen.value = false + await loadRoomMembers() + } catch (error) { + toast.add({ + title: "Teilnehmer konnte nicht eingeladen werden", + color: "error" + }) + } finally { + memberInviting.value = false + } +} + +const removeRoomMember = async (member) => { + if (!member?.matrixUserId || member.own || !canUseMatrixChat.value || !activeRoom.value?.exists) return + + memberRemovingId.value = member.matrixUserId + try { + await $api(`${activeRoomEndpoint.value}/members/${encodeURIComponent(member.matrixUserId)}`, { + method: "DELETE" + }) + + matrixMembers.value = matrixMembers.value.filter((item) => item.matrixUserId !== member.matrixUserId) + toast.add({ + title: "Teilnehmer entfernt", + color: "success" + }) + } catch (error) { + toast.add({ + title: "Teilnehmer konnte nicht entfernt werden", + color: "error" + }) + } finally { + memberRemovingId.value = "" + } +} + +const searchRoomMessages = async () => { + const query = matrixSearchQuery.value.trim() + if (query.length < 2 || !canUseMatrixChat.value || !activeRoom.value?.exists) { + matrixSearchResults.value = [] + return + } + + matrixSearchLoading.value = true + try { + const res = await $api(`${activeRoomEndpoint.value}/search?q=${encodeURIComponent(query)}`) + matrixSearchResults.value = res.results || [] + } catch (error) { + toast.add({ + title: "Suche konnte nicht ausgeführt werden", + color: "error" + }) + } finally { + matrixSearchLoading.value = false + } +} + const openMatrixCall = async (mode = "video") => { if (matrixLiveKitRoom && matrixCallConnected.value) { matrixCallOpen.value = true @@ -1708,6 +1821,59 @@ onBeforeUnmount(() => {
+
+

+ Suche +

+
+ + + +
+ +
+

+ Keine Treffer in diesem Raum. +

+
+

@@ -1715,7 +1881,7 @@ onBeforeUnmount(() => {

{ :disabled="!canUseMatrixChat || !activeRoom?.exists" @click="syncRoomMembers" /> + {
+
+ + + Einladen + +
+
{ {{ member.matrixUserId }}

+