import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" import { authProfiles, authTenantUsers, authUsers, communicationMessages, communicationRoomMembers, communicationRoomReads, communicationRooms, communicationHiddenRooms, notificationsItems, projects, } from "../../db/schema" import { NotificationService, UserDirectory } from "../modules/notification.service" import { assertCustomRoomKey, chatError, projectChatAccess } from "../modules/communication-access" type ChatUser = { userId: string email?: string | null firstName?: string | null lastName?: string | null 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) { const notifications = new NotificationService(server, getUserDirectory) const requireTenant = async (req: any) => { const tenantId = Number(req.user.tenant_id) if (!tenantId) throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 }) const [membership] = await server.db.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) .where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, req.user.user_id))).limit(1) if (!membership) chatError("Keine aktive Mandantenmitgliedschaft") 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) => server.db.transaction(async (tx) => { const generalKey = and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")) let [room] = await tx.select().from(communicationRooms) .where(generalKey).limit(1).for("update") if (!room) { const [created] = await tx.insert(communicationRooms).values({ tenantId, key: "allgemein", name: "Allgemeiner Chat", topic: "Mandantenweiter Austausch", type: "general", createdBy: creatorId, }).onConflictDoNothing().returning() room = created if (!room) { [room] = await tx.select().from(communicationRooms) .where(generalKey).limit(1).for("update") } } // Old Matrix general-room endpoints omitted the type and persisted its default 'room'. // Only unarchived, unbound tenant rooms may be adopted; a key collision must not grant access. if (!room || !["general", "room"].includes(room.type) || room.archived || room.entityType !== null || room.entityId !== null || room.entityUuid !== null) { chatError("Allgemeiner Chat ist nicht verfügbar", 409) } if (room.type === "room") { [room] = await tx.update(communicationRooms).set({ type: "general" }) .where(eq(communicationRooms.id, room.id)).returning() } const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) .where(eq(authTenantUsers.tenant_id, tenantId)) if (users.length) await tx.insert(communicationRoomMembers) .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) .onConflictDoNothing() return room }) const requireRoom = async (req: any, 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 }) if (row.room.type === "project" && !(await projectChatAccess(server.db, req, row.room.entityId)).length) { chatError("Chatraum nicht gefunden oder kein Zugriff", 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 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(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 notifyMessageRecipients = async ( tenantId: number, room: typeof communicationRooms.$inferSelect, senderId: string, message: { id: number; body: string } ) => { try { 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 = sender ? displayName(sender) : "FEDEO" await notifications.trigger({ 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?room=${encodeURIComponent(room.key)}`, roomKey: room.key, roomName: room.name, roomType: room.type, messageId: message.id, mentioned: room.type !== "direct", direct: room.type === "direct", }, channels: ["inapp", "push"], }) } catch (err) { server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden") } } 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/room-preferences", async (req: any) => { const tenantId = await requireTenant(req) const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms) .where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id))) return { hiddenRoomKeys: hidden.map(room => room.key) } }) server.put("/communication/chat/room-preferences", async (req: any, reply) => { const tenantId = await requireTenant(req) const { roomKey, hidden } = req.body || {} if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200 || !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) { return reply.code(400).send({ error: "Ungültige Raumeinstellung" }) } const value = { tenantId, userId: req.user.user_id, roomKey } if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing() else await server.db.delete(communicationHiddenRooms).where(and( eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id), eq(communicationHiddenRooms.roomKey, roomKey))) return { roomKey, hidden } }) server.get("/communication/chat/users", async (req: any) => { const tenantId = await requireTenant(req) return { users: (await tenantUsers(tenantId)).map((user) => ({ userId: user.userId, email: user.email, displayName: displayName(user), })) } }) server.get("/communication/chat/rooms", async (req: any) => { const tenantId = await 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), inArray(communicationRooms.type, ["general", "room"]), 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/chat/rooms", async (req: any, reply) => { const tenantId = await 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" }) assertCustomRoomKey(key) const userIds = (await tenantUsers(tenantId)).map((user) => user.userId) const created = await server.db.transaction(async (tx) => { const [room] = await tx.insert(communicationRooms).values({ tenantId, key, name, topic: body.topic?.trim() || null, type: "room", createdBy: req.user.user_id, }).onConflictDoNothing().returning() if (room) await tx.insert(communicationRoomMembers) .values([...new Set(userIds)].map((userId) => ({ roomId: room.id, userId }))).onConflictDoNothing() return room }) if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" }) return { ...roomToApi(created), alreadyExisted: false } }) server.get("/communication/chat/project-rooms", async (req: any) => { const tenantId = await 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))), projectChatAccess(server.db, req), ]) 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, projectId: project.id, projectNumber: project.projectNumber, exists: false, } }) } }) server.post("/communication/chat/project-rooms/:projectId/provision", async (req: any, reply) => { const tenantId = await requireTenant(req) const projectId = Number(req.params.projectId) const [project] = await projectChatAccess(server.db, req, projectId) 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) } } if (room.type !== "project" || room.entityId !== projectId || room.archived) { chatError("Projektchat ist nicht verfügbar", 409) } 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.get("/communication/chat/direct-rooms", async (req: any) => { const tenantId = await 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, } }) } }) server.post("/communication/chat/direct-rooms/:userId/provision", async (req: any, reply) => { const tenantId = await 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) } } if (room.type !== "direct" || room.archived) chatError("Direktchat ist nicht verfügbar", 409) 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/chat/unread", async (req: any) => { const tenantId = await requireTenant(req) const memberships = await server.db.select({ roomId: communicationRoomMembers.roomId, roomKey: communicationRooms.key, roomType: communicationRooms.type, projectId: communicationRooms.entityId, 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), eq(communicationRooms.archived, false))) const allowedProjects = new Set((await projectChatAccess(server.db, req)).map((project: any) => project.id)) const rooms: Record = {} for (const membership of memberships) { if (membership.roomType === "project" && !allowedProjects.has(membership.projectId)) continue 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/chat/rooms/:roomKey/messages", async (req: any) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, tenantId, req.user.user_id, req.params.roomKey) return { ...roomToApi(room), messages: await messagesForRoom(room.id, req.user.user_id, tenantId) } }) server.get("/communication/chat/rooms/:roomKey/sync", async (req: any) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, 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.post("/communication/chat/rooms/:roomKey/messages", async (req: any, reply) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, 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/chat/rooms/:roomKey/read", async (req: any) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, 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.get("/communication/chat/rooms/:roomKey/members", async (req: any) => { const tenantId = await requireTenant(req) const room = await requireRoom(req, 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, })) } }) }