From d0adcd779538ed351b0438a0ac1e6e49b56238d3 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 10:01:57 +0200 Subject: [PATCH] Phase 8 abgeschlossen: Export als PDF, Wasserzeichen, Vier-Augen-Freigabe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Druckansicht ist die einzige Vorlage für den Export: kein zweites Layout von Hand in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite auf und bekommt dafür ein signiertes, kurzlebiges Zeichen statt eines Sitzungscookies. Es gilt nur, solange der Auftrag läuft, ist an den Anfragenden gebunden, und die Stufe wird beim Einlösen frisch aufgelöst und auf die im Zeichen vermerkte gedeckelt. Der Export darf nie sauberer aussehen als die Akte: gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen, geänderte Einträge tragen ihre Version, zurückgezogene stehen als Grabstein. Wasserzeichen mit Name, Zeitstempel und Stufe auf jeder Seite. Die Stufe steckt im Zwischenspeicherschlüssel. Ohne sie bekäme ein Leser der Stufe 1 das PDF, das für Stufe 4 gerendert wurde. Ab Stufe 3 wartet der Auftrag auf Freigabe durch ein zweites Augenpaar — und der Anfragende darf sich nicht selbst freigeben, auch nicht mit dem Recht dazu. Offene Freigaben stehen auf der Übersicht. Dazu Einzeldokumente als PDF (Aktenblatt bleibt Aktenblatt), ZIP-Sammelexport mit eigenem achtzigzeiligem Schreiber statt einer Abhängigkeit, und der JSON-Vollabzug ohne Stufenfilterung — die einzige Stelle, an der die Klassifizierung nicht greift, deshalb hinter einem eigenen Admin-Endpunkt. Zwei Funde unterwegs, beide in docs/13 festgehalten: Die Druckansicht warf notFound(), und das Skelett aus dem Wurzel-loading.tsx blieb im Browser stehen — der PDF-Dienst hätte leere Blätter gedruckt. Gefunden nur, weil der e2e-Test auf den Inhalt prüft und nicht auf den Statuscode. Sie rendert die Auskunft jetzt selbst. Und die Entdopplung der Anhänge verwies auf Dateien, ohne nachzusehen, ob sie noch daliegen. Nach einem geleerten /tmp zeigte jeder weitere Upload desselben Inhalts stumm ins Leere. 178 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 44 e2e. Fallakte bei 119,8 von 120 kB — das Budget ist aufgebraucht. Co-Authored-By: Claude Opus 5 --- TASKS.md | 14 +- docs/13-entscheidungen.md | 112 ++++++ .../20260903180000_export/migration.sql | 40 ++ packages/db/prisma/schema.prisma | 61 +++ web/app/(app)/admin/system/page.tsx | 11 + .../[documentNumber]/export/page.tsx | 46 +++ .../(app)/dokumente/[documentNumber]/page.tsx | 4 + web/app/(app)/export/Approvals.tsx | 117 ++++++ web/app/(app)/export/actions.ts | 87 +++++ .../(app)/faelle/[caseNumber]/export/page.tsx | 55 +++ web/app/(app)/faelle/[caseNumber]/page.tsx | 1 + web/app/(app)/uebersicht/page.tsx | 59 +++ web/app/(print)/NotAvailable.tsx | 21 + .../dokumente/[documentNumber]/druck/page.tsx | 180 +++++++++ .../faelle/[caseNumber]/druck/page.tsx | 267 +++++++++++++ web/app/api/admin/export/route.ts | 94 +++++ web/app/api/exports/[id]/route.ts | 63 +++ web/app/globals.css | 125 +++++- web/components/export/ExportPanel.tsx | 94 +++++ web/e2e/smoke.spec.ts | 24 ++ web/lib/audit.ts | 4 + web/lib/export/cacheKey.ts | 32 ++ web/lib/export/database.ts | 122 ++++++ web/lib/export/printAccess.ts | 18 + web/lib/export/renderAccess.ts | 48 +++ web/lib/export/service.ts | 364 ++++++++++++++++++ web/lib/export/token.ts | 62 +++ web/lib/export/zip.ts | 101 +++++ web/lib/files/process.ts | 32 +- web/lib/i18n/de.ts | 2 + web/lib/i18n/export.ts | 45 +++ web/lib/realtime/publish.ts | 1 + web/tests/export.test.ts | 262 +++++++++++++ 33 files changed, 2549 insertions(+), 19 deletions(-) create mode 100644 packages/db/prisma/migrations/20260903180000_export/migration.sql create mode 100644 web/app/(app)/dokumente/[documentNumber]/export/page.tsx create mode 100644 web/app/(app)/export/Approvals.tsx create mode 100644 web/app/(app)/export/actions.ts create mode 100644 web/app/(app)/faelle/[caseNumber]/export/page.tsx create mode 100644 web/app/(print)/NotAvailable.tsx create mode 100644 web/app/(print)/dokumente/[documentNumber]/druck/page.tsx create mode 100644 web/app/(print)/faelle/[caseNumber]/druck/page.tsx create mode 100644 web/app/api/admin/export/route.ts create mode 100644 web/app/api/exports/[id]/route.ts create mode 100644 web/components/export/ExportPanel.tsx create mode 100644 web/lib/export/cacheKey.ts create mode 100644 web/lib/export/database.ts create mode 100644 web/lib/export/printAccess.ts create mode 100644 web/lib/export/renderAccess.ts create mode 100644 web/lib/export/service.ts create mode 100644 web/lib/export/token.ts create mode 100644 web/lib/export/zip.ts create mode 100644 web/lib/i18n/export.ts create mode 100644 web/tests/export.test.ts diff --git a/TASKS.md b/TASKS.md index 336e72f..a4409ee 100644 --- a/TASKS.md +++ b/TASKS.md @@ -125,12 +125,14 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. ## Phase 8 — Export -- [ ] Druckansicht `/faelle/[caseNumber]/druck` mit `@media print` -- [ ] Playwright-Container erzeugt PDF, Ergebnis gecacht -- [ ] Schwärzung und Wasserzeichen im Export -- [ ] Freigabeverfahren ab Stufe 3 -- [ ] Einzeldokument als PDF, ohne Verlauf, Aussehen aus der Vorlage -- [ ] ZIP-Sammelexport und JSON-Vollexport für Admins +- [x] Druckansicht `/faelle/[caseNumber]/druck` mit `@media print` +- [x] Playwright-Container erzeugt PDF, Ergebnis gecacht +- [x] Schwärzung und Wasserzeichen im Export +- [x] Freigabeverfahren ab Stufe 3 +- [x] Einzeldokument als PDF, ohne Verlauf, Aussehen aus der Vorlage +- [x] ZIP-Sammelexport und JSON-Vollexport für Admins +- [ ] Entscheidung offen: Fallakte aufteilen, das Bündelbudget ist aufgebraucht + (119,8 von 120 kB) — siehe docs/13-entscheidungen.md ## Phase 9 — Schliff diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index a67787e..3dd0d7b 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -718,3 +718,115 @@ Das kostet die anderen Seiten rund hundert Byte. Sie sucht bewusst schmal — Aktenzeichen, Titel, Namen, kein Volltext über Inhalte. Wer sie öffnet, will springen, nicht recherchieren. + +## Export + +### Ein Zeichen statt eines Sitzungscookies + +Der PDF-Dienst ruft die Druckansicht selbst auf und braucht dafür die Rechte des Abrufers. Der +naheliegende Weg wäre, ihm das **Sitzungscookie** mitzugeben — der Kommentar im Gerüst aus +Phase 0 schlug das sogar vor. Er ist trotzdem falsch: ein Cookie ist der volle Zugang zum +Konto und läge dann im Speicher eines zweiten Prozesses und in dessen Protokollen. + +Stattdessen ein signiertes, kurzlebiges Zeichen (`lib/export/token.ts`), das genau eines +erlaubt: diese eine Ansicht, für diesen einen Auftrag, für zehn Minuten. Drei Einschränkungen +machen es stumpf: + +- Es gilt **nur, solange der Auftrag auf `RUNNING` steht**. Davor und danach ist es wertlos. +- Die Stufe wird beim Einlösen **frisch aufgelöst und auf die im Zeichen vermerkte + gedeckelt**. Ein Zeichen kann also nie mehr zeigen, als der Abrufer beim Anfragen durfte — + und auch nicht mehr, als er jetzt noch darf, falls ihm inzwischen Rechte entzogen wurden. +- Es ist an den anfragenden Nutzer gebunden. + +Dieselbe Bauart wie die signierte Datei-URL aus `docs/07`. + +### Die Druckansicht wirft kein `notFound()` + +Sie rendert stattdessen selbst eine Nicht-verfügbar-Seite. Der Grund ist handfest: die Wurzel +hat ein `loading.tsx`, und dessen Skelett blieb in der `(print)`-Gruppe stehen, statt durch +die Nicht-gefunden-Seite ersetzt zu werden — im Browser kam nur ein leeres Gerüst an, obwohl +die Antwort den richtigen Inhalt enthielt. Ein eigenes `not-found.tsx` in der Gruppe half +nicht. + +Für ein Blatt, das gedruckt wird, ist ein leeres Blatt der schlechteste Ausgang: es sieht aus +wie ein Fehler im Papier, nicht wie eine Auskunft. Und der PDF-Dienst wartet auf `networkidle` +und druckt, was dann dasteht — er hätte leere Seiten erzeugt. + +Gefunden wurde das nur, weil der e2e-Test auf den **Inhalt** prüft und nicht auf den +Statuscode. Der wäre hier ohnehin nutzlos gewesen: Next streamt, der Kopf ist längst raus, +wenn `notFound()` greift, und die Antwort trägt 200 — auch bei den regulären Seiten. + +### Die Druckansichten liegen ausserhalb des angemeldeten Bereichs + +`app/(app)/layout.tsx` ruft `requireUser()`, und der leitet ohne Sitzung zur Anmeldung um — +womit der PDF-Dienst nie bei der Seite ankäme. Die Druckansichten liegen deshalb in einer +eigenen Gruppe `(print)` mit einem nackten Rahmen: keine Navigation, keine Kopfzeile, keine +Kommandopalette. Was auf Papier landet, soll das Dokument sein und nicht die Anwendung darum +herum. + +Jede dieser Seiten prüft dafür selbst, über `permissionsForPrint`. Ohne gültiges Zeichen und +ohne Sitzung zeigen sie „Nicht gefunden" — nicht 403, denn die Existenz ist selbst eine +Information. + +### Die Stufe steckt im Zwischenspeicherschlüssel + +`sha256(Ziel + Stand + Stufe + Vorlagenversion)`. Ohne die Stufe darin bekäme ein Leser der +Stufe 1 das PDF, das für Stufe 4 gerendert wurde — der teuerste denkbare +Zwischenspeicherfehler, und einer, der lange unbemerkt bliebe. Der Stand sorgt dafür, dass +eine geänderte Akte neu gerendert wird. + +### Vier Augen heisst vier Augen + +Ab Stufe 3 wartet ein Auftrag auf `PENDING_APPROVAL`. Freigeben darf, wer `export.approve` +hat — **ausser dem Anfragenden selbst**, auch dann nicht, wenn er das Recht besitzt. Ohne +diese eine Zeile wäre das ganze Verfahren Zierde. + +Die offenen Freigaben stehen auf der Übersicht, weil die Leitung diese Seite ohnehin öffnet. +Eine Anfrage, die niemand sieht, ist keine Freigabe. + +### Der Export darf nie sauberer aussehen als die Akte + +Gesperrte Felder erscheinen als schwarzer Balken mit dem Vermerk „Geschwärzt, Stufe 4" statt +zu fehlen; sonst merkt der Empfänger nicht, dass er eine gekürzte Fassung in der Hand hält. +Nachträglich geänderte Einträge tragen `bearbeitet, v3, 31.08.2026`, zurückgezogene stehen als +Grabstein mit Begründung. Der `Stand:`-Zeitstempel auf dem Deckblatt macht erkennbar, dass ein +älterer Ausdruck überholt sein kann. + +### Ein eigener ZIP-Schreiber + +Achtzig Zeilen statt einer Abhängigkeit. Der Inhalt eines Sammelexports sind PDFs und bereits +neu kodierte Bilder — beides komprimiert; ein Deflate darüber bringt nichts und kostet nur CPU +auf einem Heimserver. Was bleibt, ist das Behälterformat, und das ist überschaubar. + +### Der JSON-Abzug ist die eine Ausnahme + +Er läuft **ohne Stufenfilterung**, nur mit Recht `admin`, immer protokolliert. Weil er die +einzige Stelle im System ist, an der die Klassifizierung nicht greift, liegt er absichtlich +unbequem: hinter einem eigenen Endpunkt im Adminbereich, nicht hinter einem Knopf an einer +Akte. Sitzungen bleiben draussen — sie verschaffen Zugang, statt ihn zu dokumentieren. + +## Das Bündelbudget ist aufgebraucht + +Die Fallakte liegt bei **119,8 kB** gegen 120 erlaubte. In dieser Phase kostete allein das +Verschieben der Exporttexte in eine eigene i18n-Datei den Unterschied zwischen 120,3 und +119,8 — der Export selbst hat eine eigene Seite und trägt nichts zur Fallakte bei. + +Damit ist Schluss mit dem Kürzen: die nächste Insel auf dieser Seite reisst das Budget, und es +gibt nichts Offensichtliches mehr wegzunehmen. Der nächste Schritt ist, die Seite zu teilen — +Beteiligte, Verknüpfungen, Dokumente und Anhänge gehören fachlich zusammen und könnten hinter +einen Reiter oder auf eine eigene Unterseite. Das ist eine Änderung an der Bedienung und +sollte deshalb bewusst entschieden werden, nicht nebenbei beim nächsten Feature. + +## Ein Verweis in der Datenbank ist kein Beweis für eine Datei + +Die Entdopplung der Anhänge verwies auf eine vorhandene Datei, sobald eine Zeile mit gleichem +`sha256` und Zustand `CLEAN` existierte — ohne nachzusehen, ob die Datei noch daliegt. + +Aufgefallen ist das, als `/tmp` beim Neustart der Maschine geleert wurde: die Testdatenbank +kannte die alten Zeilen noch, die Ablage nicht mehr, und jeder weitere Upload desselben +Inhalts zeigte stumm ins Leere. Auf dem Heimserver wäre dasselbe passiert, wenn ein Volume +einmal nicht eingehängt gewesen wäre. + +`freigeben` prüft jetzt, ob die Datei wirklich existiert, bevor es auf sie verweist. Sonst +legt es sie neu ab. Die Regel dahinter gilt allgemein: **ein Verweis in der Datenbank ist eine +Behauptung über die Ablage, kein Beweis.** diff --git a/packages/db/prisma/migrations/20260903180000_export/migration.sql b/packages/db/prisma/migrations/20260903180000_export/migration.sql new file mode 100644 index 0000000..c529771 --- /dev/null +++ b/packages/db/prisma/migrations/20260903180000_export/migration.sql @@ -0,0 +1,40 @@ +-- Phase 8: Export. +-- +-- Der Auftrag steht in der Datenbank, nicht im Speicher eines Prozesses: das +-- Rendern dauert, und ein Neustart des Web-Containers soll einen laufenden +-- Export nicht spurlos verschlucken. + +CREATE TYPE "ExportKind" AS ENUM ('CASE', 'DOCUMENT', 'BUNDLE', 'DATABASE'); +CREATE TYPE "ExportStatus" AS ENUM ( + 'PENDING_APPROVAL', 'QUEUED', 'RUNNING', 'READY', 'FAILED', 'REJECTED' +); + +CREATE TABLE "ExportJob" ( + "id" TEXT NOT NULL, + "kind" "ExportKind" NOT NULL, + "targetId" TEXT, + "requestedById" TEXT NOT NULL, + "clearanceAtTime" INTEGER NOT NULL, + "status" "ExportStatus" NOT NULL DEFAULT 'QUEUED', + "cacheKey" TEXT NOT NULL, + "storageKey" TEXT, + "sizeBytes" INTEGER, + "error" TEXT, + "approvedById" TEXT, + "approvedAt" TIMESTAMP(3), + "rejectedById" TEXT, + "rejectReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "finishedAt" TIMESTAMP(3), + + CONSTRAINT "ExportJob_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ExportJob_status_createdAt_idx" ON "ExportJob"("status", "createdAt"); +CREATE INDEX "ExportJob_requestedById_createdAt_idx" ON "ExportJob"("requestedById", "createdAt" DESC); +CREATE INDEX "ExportJob_cacheKey_status_idx" ON "ExportJob"("cacheKey", "status"); + +-- Ein Auftrag wird nie gelöscht: er ist der Beleg dafür, wer wann was +-- ausgeleitet hat, und ergänzt das Zugriffsprotokoll um den Ausgang. +CREATE RULE export_job_no_delete AS ON DELETE TO "ExportJob" DO INSTEAD NOTHING; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9ed0051..a8fa2e7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -845,6 +845,67 @@ model SavedView { @@index([userId, sortOrder]) } +/// Ein Exportauftrag. +/// +/// Der Export laeuft im Hintergrund, weil das Rendern auf einem Heimserver +/// spuerbar CPU kostet. Der Klick erzeugt diesen Auftrag, die Oberflaeche +/// wartet auf das Ereignis `export.ready`. +/// +/// `cacheKey` ist sha256 ueber Ziel, Stand, Stufe des Abrufers und +/// Vorlagenversion: dieselbe Akte wird oft mehrfach exportiert, und zwei +/// Abrufer verschiedener Stufen bekommen **nicht** dasselbe PDF. +model ExportJob { + id String @id @default(uuid(7)) + kind ExportKind + /// Fallakte oder Dokument. Leer beim Voll- und Sammelexport. + targetId String? + requestedById String + /// Die Stufe des Abrufers zum Zeitpunkt der Anfrage. Sie steckt im + /// Schluessel und im Wasserzeichen. + clearanceAtTime Int + status ExportStatus @default(QUEUED) + cacheKey String + /// Pfad unterhalb von FILES_DIR, sobald fertig. + storageKey String? + sizeBytes Int? + error String? + + /// Vier-Augen-Prinzip ab Stufe 3, siehe docs/08-suche-und-export.md. + approvedById String? + approvedAt DateTime? + rejectedById String? + rejectReason String? + + createdAt DateTime @default(now()) + startedAt DateTime? + finishedAt DateTime? + + @@index([status, createdAt]) + @@index([requestedById, createdAt(sort: Desc)]) + @@index([cacheKey, status]) +} + +enum ExportKind { + /// eine Fallakte mit Verlauf und Anhangsverzeichnis + CASE + /// ein einzelnes Dokument, ohne Verlauf + DOCUMENT + /// mehrere Akten als ZIP, nur mit Recht 'admin' + BUNDLE + /// vollstaendiger JSON-Abzug, nur mit Recht 'admin' + DATABASE +} + +enum ExportStatus { + /// wartet auf die Freigabe durch ein zweites Augenpaar + PENDING_APPROVAL + QUEUED + RUNNING + READY + FAILED + REJECTED +} + /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. diff --git a/web/app/(app)/admin/system/page.tsx b/web/app/(app)/admin/system/page.tsx index 5bf64b7..48aa75f 100644 --- a/web/app/(app)/admin/system/page.tsx +++ b/web/app/(app)/admin/system/page.tsx @@ -69,6 +69,17 @@ export default async function SystemPage() { + +
+

{de.export.heading}

+

+ {de.export.databaseHint} +

+

+ {de.export.database} + {de.export.bundle} +

+
) } diff --git a/web/app/(app)/dokumente/[documentNumber]/export/page.tsx b/web/app/(app)/dokumente/[documentNumber]/export/page.tsx new file mode 100644 index 0000000..bf62ea8 --- /dev/null +++ b/web/app/(app)/dokumente/[documentNumber]/export/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { requireUser } from '@/lib/auth/current' +import { loadVisibleDocument } from '@/lib/documents/service' +import { APPROVAL_FROM_LEVEL } from '@/lib/export/service' +import { de } from '@/lib/i18n/de' +import { ExportPanel } from '@/components/export/ExportPanel' + +export const metadata: Metadata = { title: de.export.heading } +export const dynamic = 'force-dynamic' + +export default async function DocumentExportPage({ + params, +}: { + params: Promise<{ documentNumber: string }> +}) { + const user = await requireUser() + const { documentNumber } = await params + + const doc = await loadVisibleDocument(user.permissions, documentNumber) + if (!doc) notFound() + + return ( +
+

{doc.documentNumber}

+

{de.export.heading}

+ +
+ +
+ + {doc.classification >= APPROVAL_FROM_LEVEL ? ( +

{de.export.waitingApprovalHint}

+ ) : null} + + +
+ ) +} diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx index 8fceb71..5769b59 100644 --- a/web/app/(app)/dokumente/[documentNumber]/page.tsx +++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx @@ -212,6 +212,10 @@ export default async function DocumentPage({ +

+ {de.export.asPdf} +

+ {besitzer ? (

{'caseNumber' in besitzer ? ( diff --git a/web/app/(app)/export/Approvals.tsx b/web/app/(app)/export/Approvals.tsx new file mode 100644 index 0000000..bd7df12 --- /dev/null +++ b/web/app/(app)/export/Approvals.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useState, useTransition } from 'react' +import { exportText } from '@/lib/i18n/export' +import { commonText } from '@/lib/i18n/common' +import { approveExportAction, rejectExportAction } from './actions' + +type Auftrag = { + id: string + kind: string + label: string + requestedBy: string + clearanceAtTime: number + createdAt: string +} + +/** + * Offene Exportfreigaben. + * + * Vier-Augen-Prinzip: hier steht nur, was **jemand anderes** angefragt hat. + * Die eigene Anfrage taucht gar nicht erst auf, und der Dienst weist sie auch + * dann zurück, wenn jemand die Kennung von Hand einsetzt. + */ +export function Approvals({ jobs }: { jobs: Auftrag[] }) { + const [ablehnung, setAblehnung] = useState(null) + const [grund, setGrund] = useState('') + const [fehler, setFehler] = useState(null) + const [pending, startTransition] = useTransition() + + if (jobs.length === 0) { + return

{exportText.approvalsEmpty}

+ } + + return ( +
+
    + {jobs.map((auftrag) => ( +
  • +
    + + {exportText.kinds[auftrag.kind] ?? auftrag.kind} + + {auftrag.label} + + {exportText.requestedBy} {auftrag.requestedBy} + + + +
    + + {ablehnung === auftrag.id ? ( +
    +