Files
FEDEO/backend/src/routes/communication.ts

451 lines
23 KiB
TypeScript

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<ChatUser, "userId">) =>
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<ChatUser[]> => 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<string, { count: number; mentions: number }> = {}
for (const membership of memberships) {
const [result] = await server.db.select({ count: sql<number>`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,
})) }
})
}