From 2599a5e6f81f30f2703ffe5b7ecb18d464915fba 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. --- 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 + 15 files changed, 1362 insertions(+), 26 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/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' ? ( +