Files
AfiS-Aktensystem/web/lib/chat/service.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

466 lines
15 KiB
TypeScript

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 { visibleDepartmentWhere } from '@/lib/access/departmentFilter'
import { canEditCase } from '@/lib/access/caseWrite'
import { extractMentionNames } from '@/lib/chat/mentions'
import type { ReactionKey } from '@/lib/chat/terms'
import {
caseTopic,
departmentTopic,
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 }
| { kind: 'department'; departmentId: 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,
departmentId: 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
departmentId?: 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
}
if (scope.kind === 'department') {
const abteilung = await db.department.findFirst({
where: { AND: [visibleDepartmentWhere(p), { id: scope.departmentId }] },
select: { id: true, key: true },
})
return abteilung
? {
departmentId: abteilung.id,
topic: departmentTopic(abteilung.id),
label: abteilung.key,
}
: 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 }
: ort.personnelFileId
? { personnelFileId: ort.personnelFileId }
: { departmentId: ort.departmentId }
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 }
: ort.personnelFileId
? { personnelFileId: ort.personnelFileId }
: { departmentId: ort.departmentId }),
},
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,
departmentId: ort.departmentId ?? 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<void> {
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<void> {
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
departmentId: string | null
}): Topic {
if (nachricht.caseId) return caseTopic(nachricht.caseId)
if (nachricht.personnelFileId) return personnelTopic(nachricht.personnelFileId)
return departmentTopic(nachricht.departmentId!)
}
// ---------------------------------------------------------------------------
// 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' | 'department',
scopeId: string,
): Promise<Date | null> {
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' | 'department',
scopeId: string,
at: Date = new Date(),
): Promise<void> {
await db.readMarker.upsert({
where: { userId_scopeType_scopeId: { userId: p.userId, scopeType, scopeId } },
create: { userId: p.userId, scopeType, scopeId, lastReadAt: at },
update: { lastReadAt: at },
})
}