feat(laeufe): Uebergabeprotokoll als PDF (#041)
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:
co-authored by
Claude Opus 5
parent
d30bb6d235
commit
f3950847f3
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user