diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 512a376..b96136e 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" import { and, eq, inArray, ne } from "drizzle-orm" -import { authProfiles, authTenantUsers, authUsers, projects } from "../../db/schema" +import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema" import { matrixService } from "../modules/matrix.service" import { NotificationService, UserDirectory } from "../modules/notification.service" @@ -15,6 +15,14 @@ const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId) return rows[0] || null } +type ChatRecipient = { + userId: string + email?: string | null + firstName?: string | null + lastName?: string | null + fullName?: string | null +} + export default async function communicationRoutes(server: FastifyInstance) { const matrix = matrixService(server) const notifications = new NotificationService(server, getUserDirectory) @@ -64,6 +72,160 @@ export default async function communicationRoutes(server: FastifyInstance) { return name || user.email || "Benutzer" } + const getTenantRecipients = async (tenantId: number, senderUserId: string) => { + return 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), + ne(authTenantUsers.user_id, senderUserId) + )) + } + + 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 ( + tenantId: number, + senderUserId: string, + room: any, + text: 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) + } + + 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) + 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 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 callModeFromRequest = (req: any): "audio" | "video" => { const body = (req.body || {}) as { mode?: string } return body.mode === "audio" ? "audio" : "video" @@ -148,6 +310,32 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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/project-rooms", async (req, reply) => { try { if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" }) @@ -414,7 +602,10 @@ export default async function communicationRoutes(server: FastifyInstance) { server.post("/communication/matrix/rooms/general/messages", async (req, reply) => { try { const body = req.body as { text?: string } - return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") + const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "") + 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") } @@ -441,6 +632,16 @@ export default async function communicationRoutes(server: FastifyInstance) { } }) + 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 } + return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey) + } catch (err: any) { + return handleMatrixError(req, reply, err, "Matrix room read state failed") + } + }) + server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { try { return await matrix.getTenantRoomMessages( @@ -507,12 +708,15 @@ export default async function communicationRoutes(server: FastifyInstance) { server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => { try { const body = req.body as { text?: string } - return await matrix.sendTenantRoomMessage( + const message = await matrix.sendTenantRoomMessage( req.user.user_id, req.user.tenant_id, roomOptionsFromRequest(req), body.text || "" ) + 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") } diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index 4a4e641..5f87da8 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -3,13 +3,15 @@ import { ConnectionState, Room, RoomEvent, Track } from "livekit-client" const toast = useToast() const { $api } = useNuxtApp() +const route = useRoute() const status = ref(null) const identity = ref(null) const matrixRooms = ref([]) const matrixProjectRooms = ref([]) const matrixDirectRooms = ref([]) -const activeRoomKey = ref("allgemein") +const unreadRooms = ref({}) +const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein") const matrixMessages = ref([]) const matrixMembers = ref([]) const matrixMessageDraft = ref("") @@ -124,6 +126,8 @@ const rooms = computed(() => { ...room, group: "Räume", icon: "i-heroicons-chat-bubble-left-right", + unread: unreadRooms.value[room.key]?.count || 0, + mentions: unreadRooms.value[room.key]?.mentions || 0, description: room.alias || room.roomId || "Mandantenweiter Austausch" })) @@ -131,6 +135,8 @@ const rooms = computed(() => { ...room, group: "Projekte", icon: "i-heroicons-briefcase", + unread: unreadRooms.value[room.key]?.count || 0, + mentions: unreadRooms.value[room.key]?.mentions || 0, description: room.projectNumber || room.topic || "Projektkommunikation", provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision` })) @@ -139,6 +145,8 @@ const rooms = computed(() => { ...room, group: "Direkt", icon: "i-heroicons-user-circle", + unread: unreadRooms.value[room.key]?.count || 0, + mentions: unreadRooms.value[room.key]?.mentions || 0, description: room.email || room.topic || "Direktnachricht", provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision` })) @@ -285,6 +293,33 @@ const scrollMessagesToBottom = async () => { matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight } +const loadUnreadCounts = async () => { + if (!canUseMatrixChat.value) return + + try { + const res = await $api("/api/communication/matrix/unread") + unreadRooms.value = res.rooms || {} + } catch (error) { + unreadRooms.value = {} + } +} + +const markActiveRoomRead = async () => { + if (!canUseMatrixChat.value || !activeRoom.value?.exists) return + + try { + await $api(`${activeRoomEndpoint.value}/read`, { + method: "POST" + }) + unreadRooms.value = { + ...unreadRooms.value, + [activeRoomKey.value]: { count: 0, mentions: 0 } + } + } catch (error) { + // Lesestatus ist Komfortfunktion; Chat selbst soll dadurch nicht blockieren. + } +} + const loadChatInfo = async () => { loading.value = true try { @@ -302,6 +337,7 @@ const loadChatInfo = async () => { matrixProjectRooms.value = projectRoomsRes.rooms || [] matrixDirectRooms.value = directRoomsRes.rooms || [] lastUpdated.value = new Date() + await loadUnreadCounts() if (activeRoom.value?.exists && canUseMatrixChat.value) { await loadRoomChat({ silent: true, includeMembers: true }) @@ -403,6 +439,7 @@ const loadRoomMessages = async ({ silent = false } = {}) => { try { const res = await $api(`${activeRoomEndpoint.value}/messages`) mergeMatrixMessages(res.messages || []) + await markActiveRoomRead() matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? { ...room, alias: res.alias || room.alias, @@ -826,6 +863,7 @@ const startMatrixAutoRefresh = () => { matrixRefreshInterval = window.setInterval(() => { if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) { loadRoomChat({ silent: true }) + loadUnreadCounts() } }, 15000) } @@ -1010,6 +1048,14 @@ onBeforeUnmount(() => { > lädt + + {{ room.mentions ? `@${room.mentions}` : room.unread }} + { :disabled="room.disabled" @click="setActiveRoom(room)" > - {{ room.name }} + {{ room.name }} + + {{ room.mentions ? `@${room.mentions}` : room.unread }} +