import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { visibleEntryWhere } from '@/lib/access/caseFilter' import { canEditCase } from '@/lib/access/caseWrite' import { AccessError } from '@/lib/cases/service' import { canRetract, evaluateEntryEdit, type EditSettings } from '@/lib/cases/entryPolicy' import type { EffectivePermissions } from '@/lib/auth/permissions' /** * Nachträgliche Änderungen an Verlaufseinträgen. * * Der Kern von harter Regel 2: geändert werden darf, unsichtbar werden nie. * Jede Änderung schreibt den **vorherigen** Stand nach `CaseEntryVersion` und * erhöht die Versionsnummer. Es gibt kein Löschen, nur Zurückziehen mit * Grabstein. Siehe docs/05-akten-und-vorlagen.md. */ const MIN_REASON_LENGTH = 10 async function loadSettings(): Promise { const setting = await db.appSetting.findUnique({ where: { id: 1 } }) return { entryEditPolicy: (setting?.entryEditPolicy ?? 'GRACE_WINDOW') as EditSettings['entryEditPolicy'], entryEditGraceMinutes: setting?.entryEditGraceMinutes ?? 15, entryEditMarkGrace: setting?.entryEditMarkGrace ?? false, } } /** Lädt einen Eintrag, sofern der Nutzer ihn und seine Akte sehen darf. */ async function loadVisibleEntry(p: EffectivePermissions, entryId: string) { return db.caseEntry.findFirst({ where: { AND: [visibleEntryWhere(p), { id: entryId }] }, select: { id: true, caseId: true, type: true, body: true, minClearance: true, authorId: true, createdAt: true, version: true, retractedAt: true, case: { select: { caseNumber: true, status: true, classification: true } }, }, }) } /** Ein knapper Diff für das Protokoll, ohne den ganzen Text zu doppeln. */ function compactDiff(before: string, after: string) { return { lengthBefore: before.length, lengthAfter: after.length, firstDivergenceAt: (() => { const max = Math.min(before.length, after.length) for (let i = 0; i < max; i++) if (before[i] !== after[i]) return i return before.length === after.length ? -1 : max })(), } } export async function editEntry( p: EffectivePermissions, input: { entryId: string; body: string; minClearance?: number; changeReason?: string }, ): Promise<{ version: number; markVisible: boolean }> { const entry = await loadVisibleEntry(p, input.entryId) if (!entry) throw new AccessError('not-found') // Archiviert und versiegelt sind dicht, abgeschlossen braucht ein eigenes Recht. const caseCheck = canEditCase(p, entry.case, 'entry.create') if (!caseCheck.ok) throw new AccessError(caseCheck.reason) const settings = await loadSettings() const decision = evaluateEntryEdit(p, entry, settings) if (!decision.ok) throw new AccessError(decision.reason) const reason = input.changeReason?.trim() ?? '' if (decision.reasonRequired && reason.length < MIN_REASON_LENGTH) { throw new AccessError('reason-required') } const nextClearance = input.minClearance ?? entry.minClearance if (nextClearance !== entry.minClearance) { if (!p.isAdmin && !p.permissions.has('case.edit')) throw new AccessError('no-permission') if (nextClearance > p.clearanceLevel) throw new AccessError('clearance-too-low') } const nextVersion = entry.version + 1 const closedNote = entry.case.status === 'CLOSED' await db.$transaction(async (tx) => { // Der vorherige Stand wandert in die Versionstabelle, bevor der Eintrag // überschrieben wird. await tx.caseEntryVersion.create({ data: { entryId: entry.id, version: entry.version, body: entry.body, minClearance: entry.minClearance, changeReason: reason || null, editedById: p.userId, }, }) await tx.caseEntry.update({ where: { id: entry.id }, data: { body: input.body, minClearance: nextClearance, version: nextVersion, editedAt: new Date(), editedById: p.userId, editVisible: decision.markVisible, }, }) // Umklassifizierung wirkt rückwirkend: sonst liest jemand mit niedrigerer // Stufe den ursprünglichen Text einfach in der Historie. Das ist die // Stelle, an der eine naive Umsetzung leckt. if (nextClearance > entry.minClearance) { await tx.caseEntryVersion.updateMany({ where: { entryId: entry.id, minClearance: { lt: nextClearance } }, data: { minClearance: nextClearance }, }) } if (closedNote) { await tx.caseEntry.create({ data: { id: crypto.randomUUID(), caseId: entry.caseId, type: 'SYSTEM', body: 'Nachträgliche Änderung nach Abschluss', authorId: p.userId, minClearance: 0, }, }) } await writeAudit(tx, { actorId: p.userId, action: 'entry.edit', targetType: 'CaseEntry', targetId: entry.id, clearanceAtTime: p.clearanceLevel, meta: { caseNumber: entry.case.caseNumber, version: nextVersion, reason: reason || undefined, withinGrace: decision.withinGrace, markVisible: decision.markVisible, minClearanceFrom: entry.minClearance, minClearanceTo: nextClearance, diff: compactDiff(entry.body, input.body), }, }) }) return { version: nextVersion, markVisible: decision.markVisible } } /** * Zurückziehen statt Löschen. Der Eintrag bleibt an seiner Stelle im Verlauf * stehen und zeigt einen Grabstein. Der ursprüngliche Text bleibt in der * Datenbank und ist für Nutzer mit `audit.read` weiterhin lesbar. */ export async function retractEntry( p: EffectivePermissions, input: { entryId: string; reason: string }, ): Promise { const entry = await loadVisibleEntry(p, input.entryId) if (!entry) throw new AccessError('not-found') const caseCheck = canEditCase(p, entry.case, 'entry.create') if (!caseCheck.ok) throw new AccessError(caseCheck.reason) if (!canRetract(p, entry)) throw new AccessError('no-permission') if (entry.retractedAt) throw new AccessError('retracted') const reason = input.reason.trim() if (reason.length < MIN_REASON_LENGTH) throw new AccessError('reason-required') await db.$transaction(async (tx) => { await tx.caseEntry.update({ where: { id: entry.id }, data: { retractedAt: new Date(), retractedById: p.userId, retractionReason: reason }, }) await writeAudit(tx, { actorId: p.userId, action: 'entry.retract', targetType: 'CaseEntry', targetId: entry.id, clearanceAtTime: p.clearanceLevel, meta: { caseNumber: entry.case.caseNumber, reason }, }) }) } /** Zurückziehen lässt sich rückgängig machen, was wiederum protokolliert wird. */ export async function restoreEntry( p: EffectivePermissions, input: { entryId: string; reason: string }, ): Promise { const entry = await loadVisibleEntry(p, input.entryId) if (!entry) throw new AccessError('not-found') const caseCheck = canEditCase(p, entry.case, 'entry.create') if (!caseCheck.ok) throw new AccessError(caseCheck.reason) if (!canRetract(p, entry)) throw new AccessError('no-permission') if (!entry.retractedAt) throw new AccessError('not-retracted') const reason = input.reason.trim() if (reason.length < MIN_REASON_LENGTH) throw new AccessError('reason-required') await db.$transaction(async (tx) => { await tx.caseEntry.update({ where: { id: entry.id }, data: { retractedAt: null, retractedById: null, retractionReason: null }, }) await writeAudit(tx, { actorId: p.userId, action: 'entry.restore', targetType: 'CaseEntry', targetId: entry.id, clearanceAtTime: p.clearanceLevel, meta: { caseNumber: entry.case.caseNumber, reason }, }) }) } /** * Änderungsübersicht einer Akte: alle nachträglichen Änderungen am Verlauf, * chronologisch. Sichtbar für alle, die die Akte lesen dürfen — das ist der * ganze Sinn der Sache. */ export async function loadChangeHistory(p: EffectivePermissions, caseId: string) { return db.caseEntryVersion.findMany({ where: { entry: { AND: [visibleEntryWhere(p), { caseId }] } }, orderBy: [{ editedAt: 'desc' }], take: 200, select: { id: true, entryId: true, version: true, body: true, changeReason: true, editedById: true, editedAt: true, entry: { select: { body: true, version: true, type: true, createdAt: true } }, }, }) }