Phase 8 abgeschlossen: Export als PDF, Wasserzeichen, Vier-Augen-Freigabe

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 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 10:01:57 +02:00
co-authored by Claude Opus 5
parent 71c20f4b95
commit d0adcd7795
33 changed files with 2549 additions and 19 deletions
+112
View File
@@ -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.**