import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { AccessError, must } from '@/lib/errors' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleMessageWhere } from '@/lib/access/chatFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { canEditCase } from '@/lib/access/caseWrite' import { extractMentionNames } from '@/lib/chat/mentions' import type { ReactionKey } from '@/lib/chat/terms' import { caseTopic, personnelTopic, publish, type Topic } from '@/lib/realtime/publish' import { enqueueCaseNotification, enqueuePersonnelNotification } from '@/lib/notify/enqueue' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { Prisma } from '@afis/db' /** * Der Aktenchat. * * Er hängt immer an einer Akte — einer Fallakte oder einer Personalakte — und * ist **ausdrücklich kein Aktenbestandteil**: er ist der formlose Teil, den * die Abteilung bisher auf Discord hatte. Deshalb darf hier bearbeitet und * zurückgezogen werden, ohne dass eine Version entsteht. * * Die Brücke zum Aktenbestand ist `adoptAsEntry`: eine Nachricht wird per * Klick zu einem Vermerk. Ab da gelten die harten Regeln der Akte. * Siehe docs/06-chat-und-realtime.md. */ export type ChatScope = | { kind: 'case'; caseNumber: string } | { kind: 'personnel'; fileId: string } const NACHRICHT = { id: true, body: true, authorId: true, replyToId: true, editedAt: true, withdrawnAt: true, adoptedEntryId: true, mentions: true, createdAt: true, caseId: true, personnelFileId: true, } satisfies Prisma.ChatMessageSelect /** * Löst den Ort auf und prüft, ob der Nutzer ihn überhaupt sehen darf. * * Gibt `null` statt zu werfen, wo der Aufrufer selbst über 404 entscheidet. */ export async function resolveScope( p: EffectivePermissions, scope: ChatScope, ): Promise<{ caseId?: string; personnelFileId?: string; topic: Topic; label: string } | null> { if (scope.kind === 'case') { const akte = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { caseNumber: scope.caseNumber }] }, select: { id: true, caseNumber: true }, }) return akte ? { caseId: akte.id, topic: caseTopic(akte.id), label: akte.caseNumber } : null } // Personalakten setzen neben der Stufe ein eigenes Recht voraus. if (!p.isAdmin && !p.permissions.has('personnel.read')) return null const akte = await db.personnelFile.findFirst({ where: { AND: [visiblePersonnelWhere(p), { id: scope.fileId }] }, select: { id: true, displayName: true }, }) return akte ? { personnelFileId: akte.id, topic: personnelTopic(akte.id), label: akte.displayName } : null } /** * Die Nachrichten eines Ortes, neueste zuletzt. * * `since` blättert nach vorn und wird beim Wiederverbinden benutzt: die * Kennungen sind UUIDv7 und damit zeitlich sortiert, ein Vergleich auf der * Kennung reicht also, um „alles danach" zu holen. */ export async function listMessages( p: EffectivePermissions, scope: ChatScope, options: { since?: string; take?: number } = {}, ) { const ort = await resolveScope(p, scope) if (!ort) throw new AccessError('not-found') const wo: Prisma.ChatMessageWhereInput = ort.caseId ? { caseId: ort.caseId } : { personnelFileId: ort.personnelFileId } const nachrichten = await db.chatMessage.findMany({ where: { AND: [wo, ...(options.since ? [{ id: { gt: options.since } }] : [])] }, orderBy: { id: options.since ? 'asc' : 'desc' }, take: options.take ?? 50, select: NACHRICHT, }) if (!options.since) nachrichten.reverse() return { ort, nachrichten } } export async function loadMessage(p: EffectivePermissions, id: string) { return db.chatMessage.findFirst({ where: { AND: [visibleMessageWhere(p), { id }] }, select: NACHRICHT, }) } /** * Nachricht senden. * * Die Kennung kommt vom Client (UUIDv7) und ist zugleich Idempotenzschlüssel: * ein wiederholtes Absenden nach einem Netzfehler erzeugt keine zweite * Nachricht. Siehe docs/09-performance-und-ux.md. */ export async function sendMessage( p: EffectivePermissions, input: { id: string; scope: ChatScope; body: string; replyToId?: string | null }, ): Promise<{ id: string }> { const ort = await resolveScope(p, input.scope) if (!ort) throw new AccessError('not-found') const text = input.body.trim() if (text.length === 0) throw new AccessError('message-empty') if (text.length > 4000) throw new AccessError('message-too-long') const vorhanden = await db.chatMessage.findUnique({ where: { id: input.id }, select: { id: true }, }) if (vorhanden) return vorhanden // Erwähnungen serverseitig auflösen: der Client schickt Text, wer gemeint // ist, bestimmt der Server. Sonst könnte man jeden benachrichtigen lassen. const namen = extractMentionNames(text) const erwaehnt = namen.length ? await db.user.findMany({ where: { isActive: true, username: { in: namen, mode: 'insensitive' } }, select: { id: true }, }) : [] const absender = await db.user.findUnique({ where: { id: p.userId }, select: { displayName: true }, }) const absenderName = absender?.displayName ?? p.userId // Eine Antwort muss auf eine Nachricht desselben Ortes zeigen. let replyToId: string | null = null if (input.replyToId) { const ziel = await db.chatMessage.findFirst({ where: { id: input.replyToId, ...(ort.caseId ? { caseId: ort.caseId } : { personnelFileId: ort.personnelFileId }), }, select: { id: true }, }) replyToId = ziel?.id ?? null } const angelegt = await db.$transaction(async (tx) => { const nachricht = await tx.chatMessage.create({ data: { id: input.id, caseId: ort.caseId ?? null, personnelFileId: ort.personnelFileId ?? null, authorId: p.userId, body: text, replyToId, mentions: erwaehnt.map((user) => user.id), }, select: { id: true }, }) // Für den Worker. Hier steht nur, was hinausgehen darf — nie der Text der // Nachricht. Wer sie bekommt, entscheidet diese Anwendung, nicht er. if (ort.caseId) { const akte = await tx.case.findUniqueOrThrow({ where: { id: ort.caseId }, select: { caseNumber: true, title: true, classification: true }, }) await enqueueCaseNotification(tx, { kind: erwaehnt.length > 0 ? 'chat.mention' : 'chat.new', caseId: ort.caseId, caseNumber: akte.caseNumber, classification: akte.classification, title: akte.title, actorId: p.userId, actorName: absenderName, onlyFor: erwaehnt.length > 0 ? erwaehnt.map((u) => u.id) : undefined, meta: { messageId: nachricht.id }, }) } else if (ort.personnelFileId) { // Im Chat einer Personalakte geht nur an ausdrücklich Erwähnte etwas // hinaus, und auch das nur an die, die die Akte lesen dürfen. await enqueuePersonnelNotification(tx, { personnelFileId: ort.personnelFileId, actorId: p.userId, actorName: absenderName, mentioned: erwaehnt.map((user) => user.id), meta: { messageId: nachricht.id }, }) } return nachricht }) await publish(ort.topic, 'chat.new', angelegt.id) return angelegt } /** Bearbeiten. Sichtbar über `editedAt`, ohne Versionierung — kein Aktenbestand. */ export async function editMessage( p: EffectivePermissions, input: { id: string; body: string }, ): Promise { const nachricht = await loadMessage(p, input.id) if (!nachricht) throw new AccessError('not-found') if (nachricht.authorId !== p.userId && !p.isAdmin) throw new AccessError('no-permission') if (nachricht.withdrawnAt) throw new AccessError('message-withdrawn') const text = input.body.trim() if (text.length === 0) throw new AccessError('message-empty') await db.chatMessage.update({ where: { id: input.id }, data: { body: text, editedAt: new Date() }, }) await publish(themaVon(nachricht), 'chat.edit', input.id) } /** * Zurückziehen. Kein Löschen: eine Nachricht, auf die jemand geantwortet hat, * darf nicht spurlos verschwinden. Es bleibt ein Platzhalter stehen. */ export async function withdrawMessage( p: EffectivePermissions, id: string, ): Promise { const nachricht = await loadMessage(p, id) if (!nachricht) throw new AccessError('not-found') if (nachricht.authorId !== p.userId && !p.isAdmin) throw new AccessError('no-permission') if (nachricht.withdrawnAt) return await db.chatMessage.update({ where: { id }, data: { withdrawnAt: new Date() } }) await publish(themaVon(nachricht), 'chat.withdrawn', id) } /** Reaktion setzen oder wegnehmen. Ein Klick auf dieselbe nimmt sie zurück. */ export async function toggleReaction( p: EffectivePermissions, input: { id: string; key: ReactionKey }, ): Promise<{ active: boolean }> { const nachricht = await loadMessage(p, input.id) if (!nachricht) throw new AccessError('not-found') const schluessel = { messageId_userId_key: { messageId: input.id, userId: p.userId, key: input.key }, } const vorhanden = await db.chatReaction.findUnique({ where: schluessel }) if (vorhanden) { await db.chatReaction.delete({ where: schluessel }) } else { await db.chatReaction.create({ data: { messageId: input.id, userId: p.userId, key: input.key }, }) } await publish(themaVon(nachricht), 'chat.reaction', input.id) return { active: !vorhanden } } export async function listReactions(p: EffectivePermissions, messageIds: string[]) { if (messageIds.length === 0) return [] return db.chatReaction.findMany({ where: { messageId: { in: messageIds }, message: { is: visibleMessageWhere(p) }, }, select: { messageId: true, userId: true, key: true }, }) } /** * „Als Vermerk übernehmen". * * Die Brücke zwischen Discord-Gewohnheit und sauberem Aktenbestand: aus einer * formlosen Nachricht wird ein Verlaufseintrag, der die harten Regeln erbt — * er ist ab da nicht mehr löschbar und jede Änderung erzeugt eine Version. * * Die Nachricht bleibt stehen und wird als übernommen markiert. Der Eintrag * nennt seinen Ursprung, damit später nachvollziehbar ist, dass der Wortlaut * aus dem Chat stammt und nicht für die Akte geschrieben wurde. */ export async function adoptAsEntry( p: EffectivePermissions, messageId: string, ): Promise<{ entryId: string }> { const nachricht = await loadMessage(p, messageId) if (!nachricht) throw new AccessError('not-found') if (!nachricht.caseId) throw new AccessError('adopt-needs-case') if (nachricht.withdrawnAt) throw new AccessError('message-withdrawn') if (nachricht.adoptedEntryId) return { entryId: nachricht.adoptedEntryId } const akte = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { id: nachricht.caseId }] }, select: { id: true, caseNumber: true, title: true, status: true, classification: true }, }) if (!akte) throw new AccessError('not-found') must(canEditCase(p, akte, 'entry.create')) const autor = await db.user.findUnique({ where: { id: nachricht.authorId }, select: { displayName: true }, }) const entryId = crypto.randomUUID() await db.$transaction(async (tx) => { await tx.caseEntry.create({ data: { id: entryId, caseId: akte.id, type: 'NOTE', body: nachricht.body, authorId: nachricht.authorId, minClearance: 0, }, }) // Der Hinweis auf den Ursprung steht als eigener Systemeintrag, nicht im // Text: der Wortlaut des Vermerks bleibt der des Autors. await tx.caseEntry.create({ data: { id: crypto.randomUUID(), caseId: akte.id, type: 'SYSTEM', body: `Vermerk aus dem Aktenchat übernommen. Verfasst von ` + `${autor?.displayName ?? nachricht.authorId} am ` + `${nachricht.createdAt.toLocaleString('de-DE')}, übernommen durch ${p.userId}.`, authorId: p.userId, minClearance: 0, }, }) await tx.chatMessage.update({ where: { id: messageId }, data: { adoptedEntryId: entryId }, }) await enqueueCaseNotification(tx, { kind: 'entry.new', caseId: akte.id, caseNumber: akte.caseNumber, classification: akte.classification, title: akte.title, actorId: p.userId, actorName: autor?.displayName ?? p.userId, meta: { entryId, fromMessageId: messageId }, }) await writeAudit(tx, { actorId: p.userId, action: 'chat.adopt', targetType: 'ChatMessage', targetId: messageId, clearanceAtTime: p.clearanceLevel, meta: { caseNumber: akte.caseNumber, entryId }, }) }) await publish(caseTopic(akte.id), 'chat.adopted', messageId) await publish(caseTopic(akte.id), 'entry.new', entryId) return { entryId } } function themaVon(nachricht: { caseId: string | null; personnelFileId: string | null }): Topic { if (nachricht.caseId) return caseTopic(nachricht.caseId) return personnelTopic(nachricht.personnelFileId!) } // --------------------------------------------------------------------------- // Ungelesen // --------------------------------------------------------------------------- /** * Wann der Nutzer diesen Ort zuletzt gesehen hat. * * Der Marker wird beim Öffnen **nicht** sofort hochgesetzt, sondern erst, wenn * der Nutzer die Ansicht verlässt oder ausdrücklich weiterliest — sonst wäre * die Trennlinie „Neu seit deinem letzten Besuch" beim Neuladen sofort weg. */ export async function loadReadMarker( p: EffectivePermissions, scopeType: 'case' | 'personnel', scopeId: string, ): Promise { const marker = await db.readMarker.findUnique({ where: { userId_scopeType_scopeId: { userId: p.userId, scopeType, scopeId } }, select: { lastReadAt: true }, }) return marker?.lastReadAt ?? null } export async function markRead( p: EffectivePermissions, scopeType: 'case' | 'personnel', scopeId: string, at: Date = new Date(), ): Promise { await db.readMarker.upsert({ where: { userId_scopeType_scopeId: { userId: p.userId, scopeType, scopeId } }, create: { userId: p.userId, scopeType, scopeId, lastReadAt: at }, update: { lastReadAt: at }, }) }