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 ( +
+