451 lines
23 KiB
TypeScript
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,
|
|
})) }
|
|
})
|
|
}
|