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, notificationsItems, projects, } from "../../db/schema" import { NotificationService, UserDirectory } from "../modules/notification.service" 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 = (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 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/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/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/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/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, projectId: project.id, projectNumber: project.projectNumber, exists: false, } }) } }) 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 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 = 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 = 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) } } 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 = 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/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/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.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/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.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, })) } }) }