import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit' import { AccessError, must } from '@/lib/errors' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter' import { canEditCase } from '@/lib/access/caseWrite' import { extractMentionNames } from '@/lib/chat/mentions' import type { ReactionKey } from '@/lib/chat/terms' import { caseTopic, channelTopic, publish, type Topic } from '@/lib/realtime/publish' import { enqueueCaseNotification } from '@/lib/notify/enqueue' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { Prisma } from '@afis/db' /** * Aktenchat und freie Kanäle. * * Der Chat 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: 'channel'; key: string } const NACHRICHT = { id: true, body: true, authorId: true, replyToId: true, editedAt: true, withdrawnAt: true, adoptedEntryId: true, mentions: true, createdAt: true, caseId: true, channelId: 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; channelId?: 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 } const kanal = await db.channel.findFirst({ where: { AND: [visibleChannelWhere(p), { key: scope.key, isArchived: false }] }, select: { id: true, key: true }, }) return kanal ? { channelId: kanal.id, topic: channelTopic(kanal.id), label: kanal.key } : 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 } : { channelId: ort.channelId } 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 } : { channelId: ort.channelId }), }, 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, channelId: ort.channelId ?? 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 (erwaehnt.length > 0) { // In einem Kanal gibt es keine Akte, deren Sichtbarkeit zu prüfen wäre. // Die Mindeststufe des Kanals hat der Absender schon passiert, und // erwähnt wird nur, wer sie ebenfalls hat. await tx.notificationOutbox.createMany({ data: erwaehnt.map((user) => ({ kind: 'chat.mention', channelId: ort.channelId, actorId: p.userId, recipientId: user.id, meta: { messageId: nachricht.id, channelKey: ort.label, actorName: absenderName }, })), }) } 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; channelId: string | null }): Topic { if (nachricht.caseId) return caseTopic(nachricht.caseId) return channelTopic(nachricht.channelId!) } // --------------------------------------------------------------------------- // 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' | 'channel', 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' | 'channel', 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 }, }) } // --------------------------------------------------------------------------- // Kanäle // --------------------------------------------------------------------------- /** Die Kanäle, die dieser Nutzer sehen darf. */ export async function listChannels(p: EffectivePermissions) { return db.channel.findMany({ where: { AND: [visibleChannelWhere(p), { isArchived: false }] }, orderBy: { name: 'asc' }, select: { id: true, key: true, name: true, description: true, minClearance: true, createdAt: true, }, }) } /** * Kanal anlegen. * * Die Mindeststufe lässt sich nicht über die eigene heben — sonst legte * jemand einen Raum an, den er selbst nicht mehr betreten kann, und dieselbe * Regel gilt schon bei Akten und Dokumenten. */ export async function createChannel( p: EffectivePermissions, input: { key: string; name: string; description?: string | null; minClearance: number }, ): Promise<{ key: string }> { if (!p.isAdmin && !p.permissions.has('case.create')) throw new AccessError('no-permission') if (input.minClearance > p.clearanceLevel && !p.isAdmin) { throw new AccessError('clearance-too-low') } const key = input.key.trim().toLowerCase() if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(key)) throw new AccessError('channel-key-invalid') const vorhanden = await db.channel.findUnique({ where: { key }, select: { id: true } }) if (vorhanden) throw new AccessError('channel-key-taken') return db.$transaction(async (tx) => { const kanal = await tx.channel.create({ data: { key, name: input.name.trim().slice(0, 120), description: input.description?.trim() || null, minClearance: input.minClearance, createdById: p.userId, }, select: { key: true }, }) await writeAudit(tx, { actorId: p.userId, action: 'channel.create', targetType: 'Channel', targetId: kanal.key, clearanceAtTime: p.clearanceLevel, meta: { key: kanal.key, minClearance: input.minClearance }, }) return kanal }) }