feat(laeufe): Uebergabeprotokoll als PDF (#041)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Neben "Drucken" steht am Lauf "Als PDF", im Archiv an jedem Treffer "Protokoll
als PDF" — dasselbe Blatt als fertige Datei, zum Verschicken oder Abheften.
Unten darauf jetzt zwei Unterschriftsfelder mit Zeile fuer Ort, Datum und Namen;
wo eine Vier-Augen-Freigabe erteilt wurde, steht sie mit im Kopf.

Erzeugt wird es, indem der Server seine eigene Druckansicht ueber 127.0.0.1
laedt und druckt — mit dem Sitzungscookie des Anfragenden. Damit gibt es genau
eine Vorlage fuer Papier und Datei, und fuer das PDF gilt dieselbe Rechte- und
Mandantenpruefung wie fuer die Seite. Ein zweites Layout waere billiger und
wuerde frueher oder spaeter abweichen, ohne dass es jemandem auffaellt.

Ein eigener Browser-Kontext je PDF, kein geteilter: er traegt die fremde
Sitzung, und ein geteilter gaebe sie an das naechste PDF weiter. Gespeichert
wird nichts — ein abgelegtes PDF waere eine zweite Wahrheit neben dem Lauf.
Ein erzeugtes Passwort steht auch hier nie darauf, geprueft am extrahierten
Text des fertigen PDF und nicht an den Bytes: der Inhalt ist komprimiert, dort
nichts zu finden bewiese nichts.

Chromium bleibt optional, und die gemessene Zahl ist der Grund: das
Laufzeitbild waechst von 370 MB auf 1,52 GB, Alpines chromium kostet allein
1,13 GB. Er kommt nur mit `--build-arg MIT_PDF=1` beziehungsweise
`deploy/install.sh --mit-pdf` mit. Fehlt er, erscheint der Knopf gar nicht
erst — ein Knopf, der zuverlaessig in eine Fehlermeldung fuehrt, ist schlimmer
als keiner. Ein direkter Aufruf bekommt 503 mit einer Meldung, die sagt, was
fehlt und wo es steht; ein leeres PDF gibt es in keinem Fall.

Meine Schaetzung lag um das Dreieinhalbfache daneben: "+328 MB" war das
.apk-Paket, nicht das Bild, und stand schon im Dockerfile-Kommentar, bevor ich
gebaut hatte. Jetzt steht die gemessene Zahl an drei Stellen.

1545 Vitest-Tests (vorher 1526): 15 in tests/pdf.test.ts, die den Handler echt
fahren, 3 in tests/pdf-grenze.test.ts fuer die Obergrenze mit gestelltem
Browser, einer in tests/archiv.test.ts. Dazu drei E2E-Ablaeufe; volle Suite
gruen mit 118 bestanden. Ein Blatt wurde wirklich erzeugt und angesehen.

Sechs Gegenproben, alle angeschlagen — darunter ein echtes Passwort auf dem
Blatt und die Freigabe des Zaehlers aus dem finally genommen, der Fall, in dem
ein Fehlschlag die PDF-Erzeugung bis zum Neustart leise stilllegt.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-25 11:14:25 +01:00
co-authored by Claude Opus 5
parent d30bb6d235
commit f3950847f3
25 changed files with 1577 additions and 31 deletions
+11 -2
View File
@@ -3,6 +3,7 @@ import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archi
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { pdfVerfuegbar } from "@/server/pdf";
import {
ARCHIV_HOECHSTENS,
SUCHE_MINDESTZEICHEN,
@@ -71,10 +72,12 @@ export default async function ArchivSeite({
...(felder.laufende ? { auchLaufende: true } : {}),
};
const [ergebnis, werte, kundenEbene] = await Promise.all([
const [ergebnis, werte, kundenEbene, mitPdf] = await Promise.all([
archivSuche(benutzer, filter),
archivFilterwerte(benutzer),
kundenEbeneSichtbar(),
// Einmal fuer die ganze Liste, nicht je Zeile: es ist derselbe Server (#041).
pdfVerfuegbar(),
]);
// Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff
// hat keine Fundstellen und darf keine markieren.
@@ -117,7 +120,13 @@ export default async function ArchivSeite({
</p>
<ul className="mt-3 flex flex-col gap-3">
{ergebnis.zeilen.map((zeile) => (
<ArchivTreffer key={zeile.id} zeile={zeile} begriff={begriff} kundenEbene={kundenEbene} />
<ArchivTreffer
key={zeile.id}
zeile={zeile}
begriff={begriff}
kundenEbene={kundenEbene}
mitPdf={mitPdf}
/>
))}
</ul>
</>
+27 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
import { ArrowLeft, FileDown, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
@@ -21,6 +21,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { pdfVerfuegbar } from "@/server/pdf";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll";
import {
@@ -324,6 +325,12 @@ export default async function LaufSeite({
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
// Anhaenge dieses Laufs (#032).
const anhaenge = await anhaengeDesLaufs(id, benutzer);
/*
* Ein `access()` auf drei Pfade, nicht mehr — aber es entscheidet, ob der
* PDF-Knopf ueberhaupt erscheint. Ohne Chromium gaebe er zuverlaessig eine
* Fehlermeldung aus, und daneben steht „Drucken“, das immer tut (#041).
*/
const mitPdf = await pdfVerfuegbar();
/*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -440,6 +447,25 @@ export default async function LaufSeite({
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Drucken
</Link>
{/*
* **Kein `Link`, sondern ein `a`.** Der Endpunkt liefert ein PDF, keine
* Seite: ein `Link` wuerde Next dazu bringen, es als Navigationsziel zu
* behandeln und vorab zu laden — also ein PDF zu erzeugen, das niemand
* angesehen hat. `target` neu, damit die Abarbeitungsansicht offen
* bleibt; sie ist der Platz, an dem gerade gearbeitet wird.
*/}
{mitPdf ? (
<a
href={`/api/laeufe/${lauf.id}/protokoll.pdf`}
target="_blank"
rel="noopener"
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<FileDown className="size-4" strokeWidth={1.75} aria-hidden="true" />
Als PDF
</a>
) : null}
</div>
<div className="mt-6 flex flex-col gap-4">
@@ -0,0 +1,97 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufHolen } from "@/server/queries/laeufe";
import { PdfFehler, seiteAlsPdf } from "@/server/pdf";
/**
* Das Übergabeprotokoll eines Laufs als PDF (#041).
*
* **Route Handler prüfen selbst.** Die Hülle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab, und der Proxy sieht nur, **ob** ein Cookie da ist
* (CLAUDE.md); `tests/zugang.test.ts` erzwingt das seit #038 strukturell.
*
* Geprüft wird hier **zweimal**, und das ist Absicht:
*
* 1. `rechtErforderlich("lesen")` und `laufHolen` — damit die Antwort schon ohne
* Chromium richtig ist: ein Lauf eines fremden Kunden ergibt `404`, bevor
* überhaupt ein Browser startet. Sonst wäre ein PDF-Versuch ein Weg,
* herauszufinden, ob es einen Lauf gibt.
* 2. Die Druckansicht selbst, die Chromium dann lädt. Sie prüft ihr Recht
* ebenfalls — es gibt also keinen Zugangsweg, der etwas vergessen könnte.
*
* **Erzeugt und nicht gespeichert.** Ein gespeichertes PDF wäre eine zweite
* Wahrheit neben dem Lauf: wird eine Notiz später berichtigt, stimmt es nicht
* mehr. Für „wir brauchen das Blatt von damals“ gibt es das Archiv, das den Lauf
* unveränderlich hält.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzer: { id: string; rolle: Parameters<typeof laufHolen>[1]["rolle"] };
try {
const zugang = await rechtErforderlich("lesen");
benutzer = { id: zugang.benutzer.id, rolle: zugang.benutzer.rolle };
} catch (fehler) {
if (fehler instanceof RechteFehler) {
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
/*
* Nicht vorhanden und nicht sichtbar sind dieselbe Antwort — die
* Unterscheidung waere schon eine Auskunft ueber fremde Kunden. `laufHolen`
* wendet die Mandantensicht selbst an.
*/
const lauf = await laufHolen(id, benutzer);
if (!lauf) {
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
try {
const pdf = await seiteAlsPdf(`/drucken/lauf/${id}`, anfrage.headers.get("cookie"));
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(pdf.byteLength),
/*
* `inline`, damit der Browser es anzeigt, statt es stumm in den
* Download-Ordner zu legen — wer es behalten will, speichert aus der
* Anzeige. Der Dateiname traegt die Lauf-Kennung und keinen Titel: ein
* Titel enthaelt Leerzeichen, Umlaute und Schraegstriche, und ein
* `Content-Disposition` mit rohem Titel ist eine Einladung zum
* Kopfzeilen-Einschub.
*/
"Content-Disposition": `inline; filename="laufzettel-${id}.pdf"`,
"Cache-Control": "private, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (fehler) {
if (fehler instanceof PdfFehler) {
/*
* `503` und nicht `500`: es ist kein Fehler in diesem Code, sondern ein
* Dienst, der hier nicht eingerichtet ist — oder gerade keine Kapazitaet
* hat. Die Meldung geht hinaus, weil sie sagt, was zu tun ist, und nichts
* ueber den Server verraet.
*/
return Response.json({ fehler: fehler.message }, { status: 503 });
}
/*
* Alles andere ist ein Fehler bei uns. Die Meldung geht ins Protokoll des
* Prozesses und **nicht** in die Antwort: eine rohe Ausnahme kann Pfade und
* Zugangsdaten nennen (#025).
*/
console.error(
`PDF fuer Lauf ${id} fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return Response.json(
{ fehler: "Das PDF konnte nicht erzeugt werden. Einzelheiten stehen im Protokoll des Dienstes." },
{ status: 500 },
);
}
}