Punkt 3 der Wünsche. Die Abteilung stand als Freitext in der Personalakte. Damit liess sich nichts fragen: „SHD", „Shd" und „Sicherheitsdienst" waren drei Abteilungen. Sie ist jetzt ein eigenes Modell, im Adminbereich verwaltbar, und die drei vorhandenen Freitexte sind in der Migration zu Abteilungen geworden. **Eine Fallakte trägt keine Abteilung.** Sie gehört zu einer, wenn eine beteiligte Person ihr angehört — abgeleitet über CaseParticipant und die Personalakte. Eine eingetragene Zuordnung wäre nach zwei Wochen falsch: Leute wechseln, Beteiligte kommen dazu, und niemand pflegt das nach. Dasselbe gilt für die Dokumente: ein Dokument unmittelbar an der Abteilung wäre halb verdrahtet, weil die Vorlagen über TemplateUse nur Fall- und Personalakte kennen. Die Abteilungsseite zeigt Mitglieder, abgeleitete Fälle und Dokumente, Vermerke, Chat und ein Kreisdiagramm der Fallarten; die Übersicht eines über alle Abteilungen. Das Diagramm ist serverseitig gerendertes SVG — eine Bibliothek wäre das Teuerste auf der Seite gewesen. Die Segmente sind Abstufungen von --steel und nicht die Stufenfarben: ein Tortenstück in Oxidrot würde als „streng geheim" gelesen. Die Zusammenfassung ist die gefährliche Stelle. Eine Abteilungsseite fasst zusammen, was einzeln längst geschützt ist, und eine Zusammenfassung, die nicht filtert, verrät mehr als jede Einzelansicht. Deshalb läuft hier jede Liste und jede Zahl durch dieselben Fragmente wie überall; ein Test prüft, dass die Summe der Diagrammanteile mit der Länge der Liste übereinstimmt. Wo jemand Personalakten gar nicht sehen darf, steht null und keine Null. Vermerke erben Regel 2 unverändert: kein DELETE, Version bei jeder Änderung, Grabstein bei jeder Zurücknahme, als Regel und Trigger in der Datenbank. Ohne Korrekturkette, Anhänge und Karenzzeit — was Beweiswert hat, gehört in eine Fallakte. Nebenbei: das Zugriffsprotokoll hängt jetzt am dafür vorgesehenen Recht audit.read statt an admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
466 lines
15 KiB
TypeScript
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 },
|
|
})
|
|
}
|