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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
255 lines
8.4 KiB
TypeScript
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 } },
|
|
},
|
|
})
|
|
}
|