Files
AfiS-Aktensystem/web/lib/cases/entryService.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

255 lines
8.4 KiB
TypeScript

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<EditSettings> {
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<void> {
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<void> {
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 } },
},
})
}