From 0a7fd15ab64d72b0913a358870a37fccdf4bce55 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Tue, 1 Sep 2026 23:11:42 +0200 Subject: [PATCH] =?UTF-8?q?Phase=202=20abgeschlossen:=20nachtr=C3=A4gliche?= =?UTF-8?q?=20=C3=84nderungen,=20Grabstein,=20Richtlinie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Kern von harter Regel 2. Jede Änderung an einem Verlaufseintrag schreibt den vorherigen Stand nach CaseEntryVersion und erhöht die Versionsnummer. Autor und Entstehungszeit bleiben unangetastet, geprüft gegen die Datenbank. Die Karenzzeit wird serverseitig aus AppSetting gelesen, nie aus dem Request. Eine Änderung darin wird versioniert und protokolliert, aber nicht sichtbar markiert — ein Tippfehler, zwei Minuten später korrigiert, soll die Akte nicht mit "bearbeitet"-Marken zupflastern. Alles danach ist markiert und verlangt eine Begründung, ebenso jede Änderung an einem fremden Eintrag. Die Stelle, an der eine naive Umsetzung leckt, hat einen eigenen Test: wird minClearance eines Eintrags angehoben, erben alle bestehenden Versionen den neuen Wert. Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text einfach in der Historie. Zurückziehen setzt einen Grabstein mit Pflichtbegründung, der Text bleibt in der Datenbank. Rückgängig machen geht und wird wiederum protokolliert. Systemeinträge sind von jeder Änderung ausgenommen. Dazu die Änderungsübersicht als Reiter an der Akte — sichtbar für alle, die die Akte lesen dürfen, nicht nur für die Leitung — und die Adminseite für die Änderungsrichtlinie. Zwei Testprobleme dabei behoben: AppSetting ist eine einzige Zeile für die ganze Anwendung, deshalb laufen die Web-Tests jetzt nacheinander, und die Testdatenbank startet bei jedem Lauf im Auslieferungszustand. Vorher hing das Ergebnis davon ab, was der vorige Lauf hinterlassen hatte. 63 Tests, zweimal hintereinander grün. Bündel bei 110,5 kB von 120. Co-Authored-By: Claude Opus 5 --- TASKS.md | 11 +- packages/db/scripts/prepare-test-db.mjs | 19 ++ web/app/(app)/admin/richtlinie/PolicyForm.tsx | 108 +++++++ web/app/(app)/admin/richtlinie/actions.ts | 57 ++++ web/app/(app)/admin/richtlinie/page.tsx | 28 ++ .../(app)/faelle/[caseNumber]/CaseActions.tsx | 140 +++++++++ .../faelle/[caseNumber]/EntryActions.tsx | 141 +++++++++ .../faelle/[caseNumber]/aenderungen/page.tsx | 95 ++++++ web/app/(app)/faelle/[caseNumber]/page.tsx | 80 +++-- web/app/(app)/faelle/actions.ts | 60 ++++ web/app/(app)/layout.tsx | 7 +- web/lib/cases/entryPolicy.ts | 91 ++++++ web/lib/cases/entryService.ts | 254 ++++++++++++++++ web/lib/i18n/de.ts | 31 ++ web/tests/eintragsaenderung.test.ts | 273 ++++++++++++++++++ web/vitest.config.mts | 4 + 16 files changed, 1367 insertions(+), 32 deletions(-) create mode 100644 web/app/(app)/admin/richtlinie/PolicyForm.tsx create mode 100644 web/app/(app)/admin/richtlinie/actions.ts create mode 100644 web/app/(app)/admin/richtlinie/page.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/CaseActions.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/EntryActions.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx create mode 100644 web/lib/cases/entryPolicy.ts create mode 100644 web/lib/cases/entryService.ts create mode 100644 web/tests/eintragsaenderung.test.ts diff --git a/TASKS.md b/TASKS.md index a66b9b9..b8f365e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -44,8 +44,7 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [x] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett - [x] Aktenansicht: Kopfdaten sofort, Verlauf in eigener Suspense-Grenze (Chat: Phase 6) - [x] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7 -- [~] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag — Fachlogik und - Tests stehen, die Bedienung an der Akte fehlt noch +- [x] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag - [x] Schreibgrenzen nach `docs/03-auth-und-rechte.md`: keine Akte über der eigenen Stufe anlegen, Ändern nur mit Lesezugriff, Klassifizierung nachträglich änderbar bis zur eigenen Stufe. Kein Löschen von Akten, nur `ARCHIVED` und `SEALED` @@ -53,12 +52,12 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [x] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht hochstufen, auch nicht über einen direkten Aufruf der Server Action - [x] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3 -- [ ] Nachträgliche Änderungen an Verlaufseinträgen nach `docs/05-akten-und-vorlagen.md`: +- [x] Nachträgliche Änderungen an Verlaufseinträgen nach `docs/05-akten-und-vorlagen.md`: `CaseEntryVersion`, Karenzzeit, Pflichtbegründung, `bearbeitet · vN` mit Diff-Historie -- [ ] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen +- [x] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen - [x] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion` -- [ ] Änderungsübersicht als Reiter an der Akte -- [ ] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten +- [x] Änderungsübersicht als Reiter an der Akte +- [x] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten ## Phase 3 — Vorlagen und Abschnitte diff --git a/packages/db/scripts/prepare-test-db.mjs b/packages/db/scripts/prepare-test-db.mjs index 635cab1..9538c42 100644 --- a/packages/db/scripts/prepare-test-db.mjs +++ b/packages/db/scripts/prepare-test-db.mjs @@ -51,4 +51,23 @@ execFileSync(process.execPath, [tsxCli, 'prisma/seed.ts'], { env: testEnv, }) +// Die Änderungsrichtlinie ist eine einzige Zeile für die ganze Anwendung. Der +// Seed lässt sie bewusst in Ruhe, damit er die Einstellung des Leiters nicht +// überschreibt — in der Testdatenbank soll sie aber bei jedem Lauf im +// Auslieferungszustand starten, sonst hängt das Ergebnis davon ab, was der +// vorige Lauf hinterlassen hat. +const reset = new pg.Client({ connectionString: testUrl }) +await reset.connect() +try { + await reset.query( + `UPDATE "AppSetting" + SET "entryEditPolicy" = 'GRACE_WINDOW', + "entryEditGraceMinutes" = 15, + "entryEditMarkGrace" = false + WHERE id = 1`, + ) +} finally { + await reset.end() +} + console.log(`[test-db] ${dbName} ist auf Stand`) diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx new file mode 100644 index 0000000..cda3a21 --- /dev/null +++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { savePolicy } from './actions' + +const POLICIES = ['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN'] as const + +/** + * Nicht optimistisch: die Richtlinie wirkt sofort für alle Akten. + * Bestätigungsdialog, Wartezustand am Knopf, klare Rückmeldung. + * Siehe docs/09-performance-und-ux.md. + */ +export function PolicyForm({ + initial, +}: { + initial: { entryEditPolicy: string; entryEditGraceMinutes: number; entryEditMarkGrace: boolean } +}) { + const [policy, setPolicy] = useState(initial.entryEditPolicy) + const [grace, setGrace] = useState(initial.entryEditGraceMinutes) + const [mark, setMark] = useState(initial.entryEditMarkGrace) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + function submit() { + if (!confirm(de.settings.confirm)) return + setError(null) + startTransition(async () => { + const result = await savePolicy({ + entryEditPolicy: policy, + entryEditGraceMinutes: grace, + entryEditMarkGrace: mark, + }) + if (result.ok) setSaved(true) + else setError(result.error) + }) + } + + return ( +
+
+ {de.settings.policy} +
+ {POLICIES.map((value) => ( + + ))} +
+
+ + + + + +
+ + {saved ? ( + {de.settings.saved} + ) : null} + {error ? {error} : null} +
+
+ ) +} diff --git a/web/app/(app)/admin/richtlinie/actions.ts b/web/app/(app)/admin/richtlinie/actions.ts new file mode 100644 index 0000000..8c8850c --- /dev/null +++ b/web/app/(app)/admin/richtlinie/actions.ts @@ -0,0 +1,57 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { db } from '@/lib/db' +import { requirePermissionForAction } from '@/lib/auth/current' +import { writeAudit } from '@/lib/audit' +import { de } from '@/lib/i18n/de' + +const schema = z.object({ + entryEditPolicy: z.enum(['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN']), + entryEditGraceMinutes: z.coerce.number().int().min(0).max(1440), + entryEditMarkGrace: z.boolean(), +}) + +export type PolicyResult = { ok: true } | { ok: false; error: string } + +/** + * Die Änderungsrichtlinie gilt für die ganze Abteilung und wird serverseitig + * gelesen, nie aus dem Request. Deshalb ist das hier die einzige Stelle, an + * der sie sich ändern lässt — mit Recht `admin` und mit Protokolleintrag. + */ +export async function savePolicy(input: unknown): Promise { + const user = await requirePermissionForAction('admin') + + const parsed = schema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.case.errors.default! } + + const before = await db.appSetting.findUnique({ where: { id: 1 } }) + + await db.$transaction(async (tx) => { + await tx.appSetting.upsert({ + where: { id: 1 }, + create: { id: 1, ...parsed.data, updatedById: user.id }, + update: { ...parsed.data, updatedById: user.id }, + }) + + await writeAudit(tx, { + actorId: user.id, + action: 'roles.update', + targetType: 'AppSetting', + targetId: '1', + clearanceAtTime: user.permissions.clearanceLevel, + meta: { + from: { + policy: before?.entryEditPolicy, + grace: before?.entryEditGraceMinutes, + mark: before?.entryEditMarkGrace, + }, + to: parsed.data, + }, + }) + }) + + revalidatePath('/admin/richtlinie') + return { ok: true } +} diff --git a/web/app/(app)/admin/richtlinie/page.tsx b/web/app/(app)/admin/richtlinie/page.tsx new file mode 100644 index 0000000..6e78957 --- /dev/null +++ b/web/app/(app)/admin/richtlinie/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requirePermission } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { PolicyForm } from './PolicyForm' + +export const metadata: Metadata = { title: de.settings.heading } +export const dynamic = 'force-dynamic' + +export default async function PolicyPage() { + await requirePermission('admin') + + const setting = await db.appSetting.findUnique({ where: { id: 1 } }) + + return ( +
+

{de.settings.heading}

+

{de.settings.intro}

+ +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx new file mode 100644 index 0000000..a89fc9d --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { changeStatusAction, reclassifyAction } from '../actions' + +/** + * Statuswechsel und Umklassifizierung. + * + * Bewusst **nicht** optimistisch: beides ist folgenreich und teils nicht + * umkehrbar. Stattdessen ein sofortiger Wartezustand am Knopf und eine klare + * Rückmeldung. Siehe docs/09-performance-und-ux.md. + */ +export function CaseActions({ + caseNumber, + classification, + statusTargets, + levels, + maxLevel, +}: { + caseNumber: string + classification: number + statusTargets: string[] + levels: { level: number; name: string }[] + maxLevel: number +}) { + const [nextStatus, setNextStatus] = useState(statusTargets[0] ?? '') + const [statusReason, setStatusReason] = useState('') + const [nextLevel, setNextLevel] = useState(classification) + const [levelReason, setLevelReason] = useState('') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + const lowering = nextLevel < classification + + function run(action: () => Promise<{ ok: boolean; error?: string }>, confirmText: string) { + if (!confirm(confirmText)) return + setError(null) + startTransition(async () => { + const result = await action() + if (!result.ok) setError(result.error ?? de.case.errors.default!) + }) + } + + return ( +
+ {statusTargets.length > 0 ? ( +
+

{de.case.statusChange}

+
+ + setStatusReason(event.target.value)} + placeholder={de.entry.changeReason} + className="w-56 border border-line bg-raised px-2 py-1" + /> + +
+
+ ) : null} + +
+

{de.case.reclassify}

+
+ + {/* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher + nicht sehen durften. Deshalb ist die Begründung Pflicht. */} + {lowering ? ( + setLevelReason(event.target.value)} + placeholder={de.case.reclassifyReason} + className="w-64 border border-line bg-raised px-2 py-1" + /> + ) : null} + +
+
+ + {error ?

{error}

: null} +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx new file mode 100644 index 0000000..8153334 --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { editEntryAction, restoreEntryAction, retractEntryAction } from '../actions' + +/** + * Ändern und Zurückziehen eines Verlaufseintrags. + * + * Nicht optimistisch: die Begründung gehört erst abgeschickt und bestätigt, + * bevor der neue Text steht. Siehe docs/09-performance-und-ux.md. + */ +export function EntryActions({ + entryId, + caseNumber, + body, + retracted, + canEdit, + canRetract, +}: { + entryId: string + caseNumber: string + body: string + retracted: boolean + canEdit: boolean + canRetract: boolean +}) { + const [mode, setMode] = useState<'idle' | 'edit' | 'retract'>('idle') + const [draft, setDraft] = useState(body) + const [reason, setReason] = useState('') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + function run(action: () => Promise<{ ok: boolean; error?: string }>) { + setError(null) + startTransition(async () => { + const result = await action() + if (result.ok) { + setMode('idle') + setReason('') + } else { + setError(result.error ?? de.case.errors.default!) + } + }) + } + + if (retracted) { + if (!canRetract) return null + return ( +
+ setReason(event.target.value)} + placeholder={de.entry.retractReason} + className="w-64 border border-line bg-raised px-2 py-1" + /> + + {error ? {error} : null} +
+ ) + } + + if (mode === 'idle') { + if (!canEdit && !canRetract) return null + return ( +
+ {canEdit ? ( + + ) : null} + {canRetract ? ( + + ) : null} +
+ ) + } + + return ( +
+ {mode === 'edit' ? ( +