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:
co-authored by
Claude Opus 5
parent
71c20f4b95
commit
d0adcd7795
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user