Files
Laufzettel/docs/issues/032-anhaenge.md
T
Florian WildeandClaude Opus 5 3cbd5026f8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 23:05:00 +02:00

6.3 KiB
Raw Blame History

#032 M19 – Anhänge und Fotos an Schritten

Status: erledigt Im Tracker: #52 Meilenstein: v0.2.0 Labels: phase-2, backend, frontend, infra Geschätzt: 10 h Tatsächlich: 3 h 05 min

Ziel

„Seriennummer fotografiert“, „Typenschild“, „Zustand bei Übergabe“, „Rechnung abgelegt“ — manches lässt sich nicht abhaken, es muss gezeigt werden. Ein Schritt soll Dateien und Fotos aufnehmen können, am Handy direkt aus der Kamera.

Zu entscheiden

  • Dateien im Dateisystem, Metadaten in der Datenbank — Empfehlung. Ein Pfad aus der Umgebung (ANHAENGE_PFAD), darunter nach Kunde und Lauf gegliedert. Gegen bytea in Postgres spricht die Sicherung: ein pg_dump mit Fotos ist nach einem Jahr kein pg_dump mehr, das jemand einspielt. Gegen S3 spricht, dass diese Anwendung selbst gehostet läuft und keinen Objektspeicher voraussetzen soll. Folge, die mitgeplant werden muss: der Compose-Stand und die native Installation brauchen ein Volume, und docs/betrieb/sicherung.md muss beides nennen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt sonst teuer bezahlt hat.
  • Keine Verkleinerung auf dem Server im ersten Schritt. Ein Handyfoto hat heute 3–6 MB; eine Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die Metadaten verliert und eine Bildbibliothek ins Projekt zieht.

Aufgaben

  • Modell Anhang: schrittergebnisId, kundeId, Dateiname, Medientyp, Größe, Prüfsumme, erstelltVonId, erstelltAm; Datei daneben im Dateisystem
  • Hochladen über einen Route Handler mit rechtErforderlich — kein Weg daran vorbei, und jeder Abruf prüft Recht und Mandant, auch der des Bildes selbst
  • Grenzen: Medientypen auf Bilder und PDF, Größe je Datei und Anzahl je Lauf, beides in der Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
  • Am Handy direkt aus der Kamera (capture="environment"), am Rechner aus dem Dateiwähler
  • Der Archiv-Schreibschutz gilt auch hier: an einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg. laufMussVeraenderbarSein sitzt an genau einer Stelle — dort anschließen
  • Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
  • docs/betrieb/sicherung.md, docker-compose.yml, deploy/install.sh: Volume, Rechte, Sicherung und Rückspielung
  • Anleitung Kapitel 02 und 08

Akzeptanzkriterien

  • Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
  • Test: an einem archivierten Lauf wird das Hochladen abgewiesen
  • Test: ein Anhang eines fremden Kunden ist über seine Adresse nicht abrufbar
  • Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
  • Test: docker-compose.yml hat ein Volume für die Anhänge, und sicherung.md nennt es (wie tests/betriebsstand.test.ts es für die Datenbank schon prüft)
  • Sicherung und Rückspielung einmal wirklich gefahren, nicht nur beschrieben

Beim Umsetzen dazugekommen

  • Hochgeladen wird über eine Server Action, nicht über einen eigenen Route Handler. Die Aufgabe oben nannte einen Route Handler; der wäre eine zweite Tür mit eigener Rechteprüfung. Eine Server Action prüft dasselbe Recht an derselben Stelle wie jede andere Schreiboperation am Lauf. Ein Route Handler bleibt für das Ausliefern — dort braucht es eine Adresse, die ein <img> holen kann, und src/app/api/anhaenge/[id]/route.ts ruft selbst rechtErforderlich("lesen"), weil die Hülle unter (app) Route Handler nicht abdeckt.
  • Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen. <ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>. Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet.
  • Kein SVG, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine Liste erlaubter Medientypen und kein image/*.
  • Reihenfolge beim Anlegen: prüfen, Datei schreiben (flag: "wx"), dann die Zeile — und bei einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei.
  • Zwei Felder datei in einem Formular — und FormData.get nahm das falsche. „Foto“ und „Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst: wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe am gebauten Stand. Die Regel liegt jetzt als reine Funktion gewaehlteDatei() in src/lib, und tests/e2e/anhaenge.spec.ts lädt bewusst über den zweiten Knopf hoch. Gegenprobe gefahren: mit get statt getAll wird der Ablauf rot.
  • docs/betrieb/sicherung.md war nicht nur unvollständig, sondern an einer Stelle falsch — dort stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei Dinge statt zwei, tar für beide Betriebswege, der chown-Fallstrick nach einer Rücksicherung als root, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im selben Lauf mitnimmt, und die Gegenprobe über die Zahlen.
  • Sicherung und Rücksicherung wirklich gefahren, nicht beschrieben: Abzug gezogen, Datenbank verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte pruefsumme geprüft. Beide stimmten.
  • tests/datenbankregeln.test.ts musste nachziehen. Er zählt die handgeschriebenen Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt.
  • /daten/ ist jetzt in .gitignore. Ohne das läge das erste hochgeladene Foto im nächsten Commit.