From 3ccf3aa2d92a92dc378a7d9cc4c2e475a479d8e4 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Mon, 5 Oct 2026 20:49:26 +0000
Subject: [PATCH] feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
(#59)
---
.env.example | 11 +
.gitignore | 4 +
CHANGELOG.md | 15 +
deploy/install.sh | 13 +
docker-compose.yml | 13 +
docs/anleitung/02-laeufe-abarbeiten.md | 27 +
docs/anleitung/08-fehlerbehebung.md | 30 ++
docs/betrieb/sicherung.md | 146 +++++-
docs/devlog.md | 91 +++-
docs/issues/032-anhaenge.md | 74 ++-
docs/issues/README.md | 4 +-
.../20261004220000_anhaenge/migration.sql | 81 +++
prisma/schema.prisma | 46 ++
src/app/(app)/laeufe/[id]/page.tsx | 27 +
src/app/api/anhaenge/[id]/route.ts | 61 +++
src/components/druck/lauf-beleg.tsx | 47 ++
src/components/laeufe/schritt-anhaenge.tsx | 170 ++++++
src/lib/anhaenge.ts | 111 ++++
src/server/actions/anhaenge.ts | 109 ++++
src/server/queries/druck.ts | 19 +
src/server/services/anhaenge.ts | 262 ++++++++++
tests/anhaenge.test.ts | 486 ++++++++++++++++++
tests/betriebsstand.test.ts | 26 +
tests/datenbankregeln.test.ts | 12 +-
tests/e2e/anhaenge.spec.ts | 151 ++++++
25 files changed, 1989 insertions(+), 47 deletions(-)
create mode 100644 prisma/migrations/20261004220000_anhaenge/migration.sql
create mode 100644 src/app/api/anhaenge/[id]/route.ts
create mode 100644 src/components/laeufe/schritt-anhaenge.tsx
create mode 100644 src/lib/anhaenge.ts
create mode 100644 src/server/actions/anhaenge.ts
create mode 100644 src/server/services/anhaenge.ts
create mode 100644 tests/anhaenge.test.ts
create mode 100644 tests/e2e/anhaenge.spec.ts
diff --git a/.env.example b/.env.example
index 6e8379e..3d36044 100644
--- a/.env.example
+++ b/.env.example
@@ -19,6 +19,17 @@ APP_SECRET=
#
# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`,
# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die
+# ---- Anhaenge (#032) -------------------------------------------------------
+# Wo die Dateien der Anhaenge liegen. Im Compose-Stand setzt docker-compose.yml
+# das selbst auf /daten/anhaenge im Volume `anhaenge`; hier gilt es fuer die
+# Entwicklung und die native Installation.
+#
+# **Gehoert in jede Sicherung.** In der Datenbank stehen nur die Metadaten.
+ANHAENGE_PFAD=./daten/anhaenge
+# Hoechstgroesse je Datei in MB und Hoechstzahl Anhaenge je Lauf.
+ANHANG_MAX_MB=10
+ANHAENGE_JE_LAUF=20
+
# Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT).
POSTGRES_USER=laufzettel
POSTGRES_PASSWORD=
diff --git a/.gitignore b/.gitignore
index 502034c..51088fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,10 @@ blob-report/
# Release liegt es bei, so steht es im Auftrag.
/docs/anleitung/*.pdf
+# Hochgeladene Anhaenge (#032). Nutzdaten, nie Quellcode – in der Entwicklung
+# landen sie unter `daten/anhaenge`, im Betrieb ausserhalb des Arbeitsbaums.
+/daten/
+
# Werkzeuge
*.tsbuildinfo
.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d076b4c..198d70e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert
+- **`docs/betrieb/sicherung.md` ist neu geschrieben.** Dort stand „Kein Upload-Verzeichnis, keine
+ erzeugten Dateien“ — mit den Anhängen ist das falsch, und eine Sicherung nach der alten Anleitung
+ hätte ab #032 still Daten verloren: in der Datenbank stehen nur die Metadaten. Jetzt sind es drei
+ Dinge statt zwei, mit `tar` für beide Betriebswege, dem `chown`-Fallstrick nach einer
+ Rücksicherung als `root`, einem Sicherungsskript, das Datenbank und Anhänge im **selben** Lauf
+ mitnimmt, und einer Gegenprobe über die Zahlen. Sicherung und Rückspielung sind einmal wirklich
+ gefahren (#032)
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
sich vorher nirgends speichern ließ (#020)
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
@@ -26,6 +33,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
+- **Fotos und Dateien an einem Schritt.** „Seriennummer fotografiert“, „Typenschild“, „Zustand bei
+ Übergabe“ — manches lässt sich nicht abhaken, es muss gezeigt werden. 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, Größe und Prüfsumme. Jeder Abruf prüft Recht und Mandantengrenze — auch der des Bildes
+ selbst. Grenzen über `ANHANG_MAX_MB` (10) und `ANHAENGE_JE_LAUF` (20), und eine abgewiesene Datei
+ nennt den Grund. Kein SVG: das ist ein Dokument, das Skripte ausführt. Der Archiv-Schreibschutz
+ gilt auch hier, und die Druckansicht zeigt Bilder als Miniatur, ein PDF als Zeile (#032)
- **Abschnitte nur für bestimmte Abteilungen.** Beim Onboarding in der Buchhaltung gehören Schritte
dazu, die sonst niemand braucht. Bisher hieß die Wahl: eine Vorlage je Abteilung pflegen, oder
Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
diff --git a/deploy/install.sh b/deploy/install.sh
index 9abc3c5..9559c62 100755
--- a/deploy/install.sh
+++ b/deploy/install.sh
@@ -23,6 +23,11 @@ readonly STANDALONE="$ZIEL/.next/standalone"
# Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt
# sauber und `git pull` findet nichts Fremdes vor.
readonly HEIM="/var/cache/laufzettel"
+# Die Dateien der Anhaenge (#032). Unter /var/lib und nicht unter /opt: /opt ist
+# der Quellcode und wird bei jedem Aktualisieren angefasst, /var/lib ist
+# veraenderlicher Zustand, der bleibt. **Gehoert in jede Sicherung** -- in der
+# Datenbank stehen nur die Metadaten.
+readonly ANHAENGE="/var/lib/laufzettel/anhaenge"
readonly ETC_VERZEICHNIS="/etc/laufzettel"
readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env"
readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service"
@@ -330,6 +335,9 @@ nutzerAnlegen() {
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL"
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM"
install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS"
+ # 0750 und nicht 0755: ein Anhang kann ein Foto eines Typenschilds oder eine
+ # Rechnung sein. Nur der Dienstnutzer liest hier.
+ install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$ANHAENGE"
}
# ---------- Schritt 4: Geheimnisse und Umgebungsdatei -----------------------
@@ -365,6 +373,11 @@ DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCK
HOSTNAME="$bindAdresse"
PORT="$port"
+# Wo die Dateien der Anhaenge liegen (#032). In der Datenbank stehen nur ihre
+# Metadaten -- dieses Verzeichnis gehoert deshalb in jede Sicherung, sonst
+# zeigen die Anhaenge nach einer Ruecksicherung ins Leere.
+ANHAENGE_PFAD="$ANHAENGE"
+
# Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
AUTH_LOCAL_ENABLED="true"
diff --git a/docker-compose.yml b/docker-compose.yml
index b39a449..fd56127 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -99,9 +99,19 @@ services:
# Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
# an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true}
+ # Anhaenge und Fotos liegen im Dateisystem, nicht in der Datenbank (#032).
+ # Der Pfad zeigt in das Volume `anhaenge` unten.
+ ANHAENGE_PFAD: /daten/anhaenge
+ ANHANG_MAX_MB: ${ANHANG_MAX_MB:-10}
+ ANHAENGE_JE_LAUF: ${ANHAENGE_JE_LAUF:-20}
# Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
ports:
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"
+ volumes:
+ # **Gehoert in jede Sicherung.** Eine Sicherung, die nur `pg_dump` macht,
+ # verliert ab hier Daten: in der Datenbank stehen nur die Metadaten der
+ # Anhaenge, die Dateien liegen hier. Siehe docs/betrieb/sicherung.md.
+ - anhaenge:/daten/anhaenge
depends_on:
postgres:
condition: service_healthy
@@ -130,3 +140,6 @@ networks:
volumes:
datenbank:
+ # Die Dateien der Anhaenge (#032). Zusammen mit der Datenbank zu sichern –
+ # getrennt gesichert ergeben beide Haelften kein vollstaendiges Bild.
+ anhaenge:
diff --git a/docs/anleitung/02-laeufe-abarbeiten.md b/docs/anleitung/02-laeufe-abarbeiten.md
index 33d98ac..7680d00 100644
--- a/docs/anleitung/02-laeufe-abarbeiten.md
+++ b/docs/anleitung/02-laeufe-abarbeiten.md
@@ -195,6 +195,33 @@ Zwei Wege: die Person aus der Liste wählen, oder — wenn das Konto gerade erst
entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
das findet die Person in ihrer Historie später nichts wieder.
+## Fotos und Dateien an einem Schritt
+
+Manches lässt sich nicht abhaken, es muss gezeigt werden: das Typenschild, der Zustand bei der
+Übergabe, die Rechnung. An jedem Schritt stehen dafür zwei Knöpfe:
+
+- **Foto** öffnet am Handy direkt die Kamera. Am Rechner zeigt der Browser den Dateiwähler — dort
+ gibt es keine Kamera-App.
+- **Datei** öffnet den Dateiwähler.
+
+Abgeschickt wird **sofort nach der Wahl**, ohne zweiten Knopf. Ein Foto, das du gerade gemacht hast,
+willst du nicht noch bestätigen.
+
+Erlaubt sind **Bilder** (JPEG, PNG, WebP, HEIC) und **PDF**, höchstens 10 MB je Datei und 20 Dateien
+je Lauf. Beide Grenzen lassen sich beim Betrieb ändern (`ANHANG_MAX_MB`, `ANHAENGE_JE_LAUF`). Eine zu
+große Datei wird schon im Browser abgewiesen — am Handy im Mobilfunk ist das der Unterschied zwischen
+einer Meldung und einer Minute Warten.
+
+Bilder erscheinen als Miniatur, ein PDF als Zeile mit Namen. Ein Klick öffnet die Datei in einem
+neuen Tab; ein PDF wird dabei heruntergeladen statt im Browser angezeigt.
+
+**An einem archivierten Lauf kommt kein Anhang mehr dazu und keiner weg** — dieselbe Regel wie für
+Schritte und Kommentare. Ansehen geht weiter: ein Foto vom Typenschild ist Teil des Belegs.
+
+> **Für den Betrieb wichtig:** Die Dateien liegen im Dateisystem, in der Datenbank stehen nur ihre
+> Namen und Größen. Eine Sicherung, die nur die Datenbank mitnimmt, verliert sie — siehe
+> `docs/betrieb/sicherung.md`.
+
## Abschließen
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
diff --git a/docs/anleitung/08-fehlerbehebung.md b/docs/anleitung/08-fehlerbehebung.md
index 6ee2620..d9d3f56 100644
--- a/docs/anleitung/08-fehlerbehebung.md
+++ b/docs/anleitung/08-fehlerbehebung.md
@@ -77,6 +77,36 @@ Zurückholen wird protokolliert.
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
+## „Die Datei zu … fehlt im Dateisystem“
+
+**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast
+immer heißt das: es wurde eine Sicherung eingespielt, die nur die Datenbank enthielt. Die Dateien der
+Anhänge liegen im Dateisystem, nicht in der Datenbank.
+
+**Was tun:** Das Verzeichnis aus derselben Sicherungsrunde zurückspielen —
+`docs/betrieb/sicherung.md` beschreibt beide Wege. Gibt es kein Archiv der Dateien, sind sie weg; die
+Zeilen lassen sich dann nur entfernen, indem jemand den Anhang in der Oberfläche löscht.
+
+**Zweitursache, seltener:** nach einer Rücksicherung gehören die Dateien `root` statt dem
+Dienstnutzer. Der Dienst kommt dann nicht an sie heran, obwohl sie daliegen:
+
+```bash
+sudo ls -l /var/lib/laufzettel/anhaenge
+sudo chown -R laufzettel:laufzettel /var/lib/laufzettel/anhaenge
+```
+
+**Vorbeugen:** Datenbank und Anhänge im **selben** Skript sichern, direkt hintereinander. Getrennt
+gesichert passen die beiden Hälften irgendwann nicht mehr zusammen.
+
+## „Dateien vom Typ … lassen sich nicht anhängen“
+
+**Ursache:** Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF. Alles andere wird abgewiesen —
+auch SVG, und das ist Absicht: ein SVG ist ein Dokument, das Skripte ausführen kann, und ein Anhang,
+den jemand öffnet, soll kein Angriffsweg sein.
+
+**Was tun:** Ein Office-Dokument oder eine Textdatei als PDF ausgeben. Ein Screenshot wird zu PNG,
+sobald du ihn speicherst statt ihn aus der Zwischenablage zu ziehen.
+
## „Kein Laufzettel-Tag“ nach dem Scannen
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
diff --git a/docs/betrieb/sicherung.md b/docs/betrieb/sicherung.md
index 899682c..0a3e38d 100644
--- a/docs/betrieb/sicherung.md
+++ b/docs/betrieb/sicherung.md
@@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
## Was gesichert werden muss
-Zwei Dinge, und nur diese zwei:
+Drei Dinge, und nur diese drei:
| | Was | Wo |
|---|---|---|
-| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
+| **1** | **Die Datenbank.** Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
+| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
+
+> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
+> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
+> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen
+> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt.
+> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert.
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
@@ -29,9 +36,8 @@ Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restli
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
-**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
-Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
-`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
+**Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next`
+entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
@@ -73,6 +79,28 @@ Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` weg
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
+#### Und die Anhänge
+
+Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien.
+
+```bash
+docker run --rm \
+ -v laufzettel_anhaenge:/daten:ro \
+ -v "$PWD":/ziel \
+ debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten .
+```
+
+Kurz nachsehen, dass etwas drin ist:
+
+```bash
+tar tzf anhaenge-$(date +%F).tar.gz | head -5
+tar tzf anhaenge-$(date +%F).tar.gz | wc -l
+```
+
+Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker
+volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt
+aus dem Verzeichnisnamen.
+
### Zurückspielen
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
@@ -91,10 +119,18 @@ docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
# 3. Abzug einspielen
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
-# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
+# 4. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien
+# liegen, auf die keine Zeile mehr zeigt
+docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true
+docker run --rm \
+ -v laufzettel_anhaenge:/daten \
+ -v "$PWD":/quelle:ro \
+ debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten
+
+# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
docker compose run --rm migrate pnpm exec prisma migrate deploy
-# 5. Wieder anfahren
+# 6. Wieder anfahren
docker compose start web
curl -s http://127.0.0.1:3000/api/gesundheit
```
@@ -118,6 +154,14 @@ auf —, aber alles, was er anlegen würde, steht schon im Abzug.
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
```
+Und die Anhänge dazu — gewöhnliche Dateien, also `tar`:
+
+```bash
+# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
+ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
+sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
+```
+
Und das Geheimnis, einmalig und in den Passwortmanager:
```bash
@@ -141,16 +185,29 @@ sudo -u postgres createdb -O laufzettel laufzettel
# 3. Abzug einspielen
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
-# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
+# 4. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
+# die keine Zeile mehr zeigt
+ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
+sudo rm -rf "$ANHAENGE"
+sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
+sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
+sudo chown -R laufzettel:laufzettel "$ANHAENGE"
+
+# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code
sudo bash /opt/laufzettel/deploy/install.sh
-# 5. Wieder anfahren und nachsehen
+# 6. Wieder anfahren und nachsehen
sudo systemctl start laufzettel
curl -s http://127.0.0.1:3000/api/gesundheit
```
-Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
-neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
+Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
+neu startet — Schritt 6 ist dann nur noch die Gegenprobe.
+
+**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem
+Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer
+`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie
+daliegt. Willst du nur die Migrationen:
```bash
cd /opt/laufzettel
@@ -170,11 +227,30 @@ After=postgresql.service
[Service]
Type=oneshot
-User=postgres
-# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
-# aelter als 30 Tage weg.
-ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
-ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
+# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
+# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
+# weiterhin als `postgres`, ueber `runuser`.
+User=root
+ExecStart=/usr/local/sbin/laufzettel-sichern
+ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete
+```
+
+`/usr/local/sbin/laufzettel-sichern`, Modus 0750:
+
+```bash
+#!/bin/bash
+# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben
+# beide Haelften kein vollstaendiges Bild (#032).
+set -Eeuo pipefail
+tag=$(date +%F)
+runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump"
+
+# shellcheck source=/dev/null
+. /etc/laufzettel/laufzettel.env
+anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
+if [ -d "$anhaenge" ]; then
+ tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
+fi
```
`/etc/systemd/system/laufzettel-sicherung.timer`:
@@ -193,19 +269,22 @@ RandomizedDelaySec=15m
WantedBy=timers.target
```
+Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die
+nur im Timer steht, hat noch nie eine Datei erzeugt:
+
```bash
+sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
sudo install -d -o postgres -g postgres -m 0700 /var/backups
sudo systemctl daemon-reload
sudo systemctl enable --now laufzettel-sicherung.timer
+sudo systemctl start laufzettel-sicherung.service
+ls -l /var/backups/laufzettel-*
sudo systemctl list-timers laufzettel-sicherung.timer
```
-Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
-
-```bash
-sudo systemctl start laufzettel-sicherung.service
-ls -l /var/backups/
-```
+Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-.dump` und
+`laufzettel-anhaenge-.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
+`ANHAENGE_PFAD` zeigt woandershin als gedacht.
### Compose, als Cron-Eintrag
@@ -218,6 +297,12 @@ In der Crontab von root (`sudo crontab -e`):
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
+Und die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen:
+
+```cron
+20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete
+```
+
### Und dann weg vom Rechner
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
@@ -258,6 +343,21 @@ sudo -u postgres dropdb probe
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
+### Und die Anhänge mitprüfen
+
+Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden
+Hälften:
+
+```bash
+# Compose: Zeilen in der Datenbank …
+docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"'
+# … gegen Dateien im Archiv
+tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true
+```
+
+Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides
+zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander.
+
## Umzug auf einen neuen Server
Derselbe Weg, nur mit einer Zwischenstation:
@@ -284,6 +384,6 @@ Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
-| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
+| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 5 oben. |
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
diff --git a/docs/devlog.md b/docs/devlog.md
index eef83cd..8f7f9aa 100644
--- a/docs/devlog.md
+++ b/docs/devlog.md
@@ -24,12 +24,19 @@ als Unterpunkte mit Aufgabe und Dauer.
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
| #026 | Zwei Konventionen präzisieren | 20 min |
-| | **Summe** | **36 h 05 min** |
+| #020 | Vorgesetzter auch für von Hand angelegte Personen | 1 h 20 min |
+| #021 | Stabiler Schlüssel für Seed-Vorlagen | 55 min |
+| #022 | Schnellstart je Gerätetyp pflegen | 1 h 10 min |
+| #023 | Protokoll ansehen | 1 h 50 min |
+| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min |
+| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
+| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
+| | **Summe** | **48 h 25 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
-| v0.2.0 | 45 min | in Arbeit |
+| v0.2.0 | 13 h 05 min | in Arbeit |
---
@@ -1089,3 +1096,83 @@ Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
davon steht geschrieben, statt verschwiegen zu werden.
+
+---
+
+## 2026-10-04 · Sitzung 17 · der ganze Tag
+
+**Issues:** #021, #020, #022, #023, #046/#047, Phase-2-Plan, #030 (M17), #031 (M18), #032 (M19)
+**Version:** 0.2.0 (unveröffentlicht)
+
+Eine lange Sitzung mit acht zusammengeführten Änderungen. Der Devlog-Eintrag kommt am Ende des Tages
+und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal trägt.
+
+### Erledigt
+
+- **Der Rückstand für `v0.2.0` ist leer.** Stabiler Seed-Schlüssel (#021), Vorgesetzter für von Hand
+ angelegte Personen (#020), Schnellstart je Gerätetyp in der Oberfläche pflegen (#022), und das
+ Protokoll ist ansehbar geworden (#023) — geschrieben wurde es seit dem Datenmodell, ansehen konnte
+ es niemand.
+- **Phase 2 in acht Meilensteine geschnitten** (#028–#035), mit drei Entscheidungen, die vor dem
+ ersten Codepfad fallen mussten.
+- **M17 Bausteine** (#030): ein Abschnitt, den mehrere Vorlagen teilen, versioniert nach denselben
+ Regeln wie eine Vorlage. Eingesetzt wird er als tiefe Kopie beim **Veröffentlichen** — damit ändert
+ eine neue Baustein-Fassung keine veröffentlichte Vorlagenversion und keinen laufenden Lauf.
+- **M18 Abteilungsabschnitte** (#031): ein Abschnitt kann sagen, für welche Abteilungen er gilt.
+ Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben.
+- **M19 Anhänge** (#032): Fotos und Dateien an einem Schritt, am Handy direkt aus der Kamera. Dateien
+ im Dateisystem, Metadaten in der Datenbank, jeder Abruf prüft Recht und Mandant.
+- **Sicherung und Rücksicherung der Anhänge wirklich gefahren** — Abzug gezogen, Datenbank verworfen,
+ Verzeichnis gelöscht, zurückgespielt, und die ausgelieferten Bytes gegen die gespeicherte
+ `pruefsumme` gehalten.
+- 1246 Vitest-Tests (von 1130 am Morgen), alle Schranken grün.
+
+### Entscheidungen
+
+- **Ein Baustein wird beim Veröffentlichen aufgelöst, nicht beim Lesen.** Eine Auflösung zur Laufzeit
+ wäre weniger Code gewesen und hätte die Zusage „eine veröffentlichte Version wird nie verändert“
+ gebrochen — über eine Hintertür, die niemand so nennt.
+- **`Lauf.geltendeAbschnitte` ist leer heißt „alle“.** Damit verhalten sich Läufe von vor #031
+ unverändert, ohne Migration von Daten.
+- **Anhänge ins Dateisystem, nicht als `bytea`.** Ein `pg_dump` mit Fotos ist nach einem Jahr kein
+ Abzug mehr, den jemand einspielt. Der Preis ist die zweite Hälfte der Sicherung, und der steht
+ jetzt in `docs/betrieb/sicherung.md`.
+- **Hochgeladen wird über eine Server Action, nicht über einen zweiten Endpunkt.** Das Issue hatte
+ einen Route Handler vorgesehen; der wäre eine zweite Tür mit eigener Rechteprüfung. Der Route
+ Handler bleibt für das Ausliefern, und er ruft `rechtErforderlich` selbst — die Hülle unter `(app)`
+ deckt Route Handler nicht ab.
+- **Die Regel „geltende Abschnitte“ liegt in `src/lib`**, nicht in einem Lesemodell. Drei Schichten
+ brauchen sie; lebte sie in einem Lesemodell, importierte ein Dienst daraus.
+
+### Probleme
+
+- **Ein Fehler, den neunzehn grüne Tests nicht sehen konnten.** Der Schritt hat zwei Felder `datei`
+ in einem Formular — „Foto“ und „Datei“ —, abgeschickt wird immer beides, und `FormData.get` nahm
+ das erste, also das leere. Wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging.
+ Kein Dienst-Test schickt zwei Felder; gefunden hat es erst die Klickprobe am gebauten Stand. Die
+ Lehre ist dieselbe wie in #019: **Fehler im dokumentierten Weg eines Dritten sind für eine grüne
+ Suite unsichtbar.** Seitdem gibt es `tests/e2e/anhaenge.spec.ts`, der bewusst über den zweiten
+ Knopf hochlädt, und die Gegenprobe wurde gefahren: mit `get` statt `getAll` wird er rot.
+- **`docs/betrieb/sicherung.md` enthielt einen falschen Satz**, nicht bloß eine Lücke: „Kein
+ Upload-Verzeichnis, keine erzeugten Dateien.“ Mit den Anhängen hätte eine Sicherung nach dieser
+ Anleitung still Daten verloren. Die Datei ist neu geschrieben.
+- **Zweimal habe ich dem Produkt angelastet, was mein Prüfskript falsch machte** — ein zu weiter
+ `role="status"`-Selektor und eine Prüfung direkt nach `reload()`, als `networkidle` zu früh
+ zurückkam. Die Datenbank hat beide Male entschieden, und sie hat dem Produkt recht gegeben.
+- **Einmal die Ursache falsch benannt.** Ich hielt eine `positionenSauber`-Prüfung für die Behebung
+ des Schnellstart-Fehlers; die Gegenprobe zeigte, dass die Tests ohne sie grün bleiben. Die
+ Behebung ist der deterministische Stichentscheid beim Sortieren.
+- **Ein Numerusfehler in einem eigenen Hinweistext** („Ein Abschnitt gilt … und *bleiben* weg“),
+ gefunden beim Durchklicken. Alles, was ein Mensch liest, schreibt sich richtig.
+- **`tests/datenbankregeln.test.ts` wurde rot** — genau wie gewollt: er zählt die handgeschriebenen
+ Datenbankobjekte namentlich auf, und die zwei neuen CHECK-Bedingungen und zwei zusammengesetzten
+ Fremdschlüssel standen nicht drin.
+- **`/daten/` fehlte in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten Commit.
+
+### Nächster Schritt
+
+- **M20 (#033)** Live-Aktualisierung per SSE statt Polling, dann **M21 (#034)** Benachrichtigungen,
+ **M22 (#035)** Selbstauskunft. **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden
+ großen verbliebenen.
+- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
+- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen.
diff --git a/docs/issues/032-anhaenge.md b/docs/issues/032-anhaenge.md
index 2d748d7..4882eca 100644
--- a/docs/issues/032-anhaenge.md
+++ b/docs/issues/032-anhaenge.md
@@ -1,10 +1,10 @@
# #032 M19 – Anhänge und Fotos an Schritten
-**Status:** offen
+**Status:** erledigt
**Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend, infra
-**Geschätzt:** 10 h **Tatsächlich:** –
+**Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min
## Ziel
@@ -14,7 +14,7 @@ können, am Handy direkt aus der Kamera.
## Zu entscheiden
-- [ ] **Dateien im Dateisystem, Metadaten in der Datenbank** — Empfehlung. Ein Pfad aus der
+- [x] **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
@@ -23,32 +23,70 @@ können, am Handy direkt aus der Kamera.
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
+- [x] **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,
+- [x] 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
+- [x] 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
+- [x] 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
+- [x] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler
+- [x] **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,
+- [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
+- [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
Sicherung **und** Rückspielung
-- [ ] Anleitung Kapitel 02 und 08
+- [x] 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
+- [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
+- [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
+- [x] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar
+- [x] Test: eine zu große Datei und ein falscher Medientyp werden mit Grund abgewiesen
+- [x] 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
+- [x] 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 `` 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.** `///.`.
+ 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.
diff --git a/docs/issues/README.md b/docs/issues/README.md
index 5b66f0f..abdcd6d 100644
--- a/docs/issues/README.md
+++ b/docs/issues/README.md
@@ -36,8 +36,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #029 | [M16 – SCIM-Endpunkt als Alternative zum Abgleich](029-scim-endpunkt.md) | offen | v0.2.0 | 12 h | – |
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
-| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | offen | v0.2.0 | 10 h | – |
+| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
-| | **Summe** | | | **229 h** | **45 h 20 min** |
+| | **Summe** | | | **229 h** | **48 h 25 min** |
diff --git a/prisma/migrations/20261004220000_anhaenge/migration.sql b/prisma/migrations/20261004220000_anhaenge/migration.sql
new file mode 100644
index 0000000..534fa5d
--- /dev/null
+++ b/prisma/migrations/20261004220000_anhaenge/migration.sql
@@ -0,0 +1,81 @@
+-- ---------------------------------------------------------------------------
+-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
+-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
+-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
+-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
+-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
+--
+-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
+-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
+-- ---------------------------------------------------------------------------
+
+-- Anhaenge und Fotos an Schritten (#032).
+--
+-- Die Datei liegt im Dateisystem unter ANHAENGE_PFAD, hier stehen die
+-- Metadaten. Gegen bytea spricht die Sicherung: ein pg_dump mit Fotos ist nach
+-- einem Jahr keines, das jemand einspielt.
+
+CREATE TABLE "Anhang" (
+ "id" TEXT NOT NULL,
+ "schrittergebnisId" TEXT NOT NULL,
+ "laufId" TEXT NOT NULL,
+ "kundeId" TEXT NOT NULL,
+ "dateiname" TEXT NOT NULL,
+ "medientyp" TEXT NOT NULL,
+ "groesse" INTEGER NOT NULL,
+ "pruefsumme" TEXT NOT NULL,
+ "erstelltVonId" TEXT,
+ "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "Anhang_schrittergebnisId_idx" ON "Anhang"("schrittergebnisId");
+CREATE INDEX "Anhang_laufId_idx" ON "Anhang"("laufId");
+CREATE INDEX "Anhang_kundeId_idx" ON "Anhang"("kundeId");
+
+-- Schluesselkandidaten fuer die Mandantengrenze unten. Lauf hatte bisher keinen –
+-- die vier bestehenden zusammengesetzten Fremdschluessel zeigen auf Geraet,
+-- Mitarbeiter und Abteilung, nicht auf Lauf.
+CREATE UNIQUE INDEX "Schrittergebnis_id_laufId_key" ON "Schrittergebnis"("id", "laufId");
+CREATE UNIQUE INDEX "Lauf_id_kundeId_key" ON "Lauf"("id", "kundeId");
+
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_schrittergebnisId_fkey"
+ FOREIGN KEY ("schrittergebnisId") REFERENCES "Schrittergebnis"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_laufId_fkey"
+ FOREIGN KEY ("laufId") REFERENCES "Lauf"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_kundeId_fkey"
+ FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_erstelltVonId_fkey"
+ FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- ---- Mandantengrenze und Zusammenhalt --------------------------------------
+--
+-- Dieselbe Bauart wie bei Lauf und Geraet: ein Anhang kann strukturell nicht zu
+-- einem Schrittergebnis eines anderen Laufs und nicht zu einem anderen Kunden
+-- als sein Lauf gehoeren.
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_ergebnis_gleicher_lauf"
+ FOREIGN KEY ("schrittergebnisId", "laufId") REFERENCES "Schrittergebnis" ("id", "laufId")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_lauf_gleicher_kunde"
+ FOREIGN KEY ("laufId", "kundeId") REFERENCES "Lauf" ("id", "kundeId")
+ ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- Eine Groesse von 0 waere eine leere Datei, und eine negative gibt es nicht.
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_groesse_positiv" CHECK ("groesse" > 0);
+
+-- SHA-256 als Hex: 64 Zeichen, nur Ziffern und a-f. Eine Pruefsumme, die keine
+-- ist, wuerde nach einer Ruecksicherung nichts beantworten.
+ALTER TABLE "Anhang"
+ ADD CONSTRAINT "Anhang_pruefsumme_form" CHECK ("pruefsumme" ~ '^[0-9a-f]{64}$');
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c96f83e..b40e7ac 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -134,6 +134,7 @@ model Kunde {
benutzerbindungen BenutzerKunde[]
geraete Geraet[]
laeufe Lauf[]
+ anhaenge Anhang[]
mitarbeiter Mitarbeiter[]
protokolle Protokoll[]
verzeichnisquellen Verzeichnisquelle[]
@@ -172,6 +173,7 @@ model Benutzer {
protokolle Protokoll[]
schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
+ anhaenge Anhang[]
@@index([aktiv])
@@index([rolle])
@@ -635,7 +637,10 @@ model Lauf {
ergebnisse Schrittergebnis[]
kommentare Kommentar[]
protokolle Protokoll[]
+ anhaenge Anhang[]
+ /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
+ @@unique([id, kundeId])
@@index([kundeId, status])
@@index([geraetId, status])
@@index([mitarbeiterId])
@@ -665,11 +670,52 @@ model Schrittergebnis {
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
kommentare Kommentar[]
+ anhaenge Anhang[]
@@unique([laufId, vorlagenSchrittId])
+ /// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
+ @@unique([id, laufId])
@@index([laufId, zustand])
}
+/// Eine Datei oder ein Foto an einem Schritt (#032).
+///
+/// Die Datei selbst liegt im Dateisystem unter `ANHAENGE_PFAD`; hier stehen nur
+/// die Metadaten. Gegen `bytea` spricht die Sicherung: ein `pg_dump` mit Fotos
+/// ist nach einem Jahr kein `pg_dump` mehr, das jemand einspielt. **Folge:** die
+/// Sicherung muss Datenbank **und** Verzeichnis mitnehmen, siehe
+/// `docs/betrieb/sicherung.md`.
+model Anhang {
+ id String @id @default(cuid())
+ schrittergebnisId String
+ laufId String
+ /// Jede kundenbezogene Zeile traegt kundeId (CLAUDE.md). Die Mandantengrenze
+ /// steht zusaetzlich als zusammengesetzter Fremdschluessel in der Migration.
+ kundeId String
+ /// Der Name, den der Browser mitgeschickt hat – nur zur Anzeige. Der Pfad auf
+ /// der Platte entsteht aus `id`, damit kein Dateiname von aussen einen Pfad
+ /// bestimmt.
+ dateiname String
+ medientyp String
+ groesse Int
+ /// SHA-256 der Datei. Beantwortet nach einer Ruecksicherung die Frage, ob die
+ /// Datei noch die ist, die hier beschrieben steht.
+ pruefsumme String
+ erstelltVonId String?
+ erstelltAm DateTime @default(now())
+
+ schrittergebnis Schrittergebnis @relation(fields: [schrittergebnisId], references: [id], onDelete: Cascade)
+ lauf Lauf @relation(fields: [laufId], references: [id], onDelete: Restrict)
+ kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
+ /// Leer bei einem Systemlauf – kein Codepfad nimmt an, dass ein Mensch
+ /// dahintersteht.
+ erstelltVon Benutzer? @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
+
+ @@index([schrittergebnisId])
+ @@index([laufId])
+ @@index([kundeId])
+}
+
model Kommentar {
id String @id @default(cuid())
laufId String
diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx
index 4321681..b4d2d0a 100644
--- a/src/app/(app)/laeufe/[id]/page.tsx
+++ b/src/app/(app)/laeufe/[id]/page.tsx
@@ -15,10 +15,12 @@ import {
LaufMitAdKontoVerknuepfen,
LaufZurueckholen,
} from "@/components/laeufe/lauf-aktionen";
+import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
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 { anhaengeDesLaufs } from "@/server/services/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll";
import {
kommentareDesLaufs,
@@ -82,12 +84,14 @@ function Schritt({
lesend,
ausfuehrlich,
kommentare,
+ anhaenge,
}: {
schritt: AbarbeitungsSchritt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
+ anhaenge: readonly SchrittAnhang[];
}) {
const vomTisch =
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
@@ -197,6 +201,22 @@ function Schritt({
) : null}
+ {/*
+ * Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
+ * archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
+ * ist Teil des Belegs.
+ */}
+ {anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
+
+
+
+ ) : null}
+
{lesend || !schritt.ergebnisId ? null : (
@@ -220,12 +240,14 @@ function Abschnitt({
lesend,
ausfuehrlich,
kommentare,
+ anhaenge,
}: {
abschnitt: AbarbeitungsAbschnitt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
+ anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
}) {
const erledigt = abschnitt.schritte.filter(
(schritt) =>
@@ -263,6 +285,7 @@ function Abschnitt({
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
+ anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
/>
))}
@@ -297,6 +320,9 @@ export default async function LaufSeite({
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
// Einträge dieses Laufs (#023).
const laufProtokoll = await protokollDesLaufs(id);
+ // Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
+ // Anhaenge dieses Laufs (#032).
+ const anhaenge = await anhaengeDesLaufs(id);
/*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -394,6 +420,7 @@ export default async function LaufSeite({
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare}
+ anhaenge={anhaenge}
/>
))}
diff --git a/src/app/api/anhaenge/[id]/route.ts b/src/app/api/anhaenge/[id]/route.ts
new file mode 100644
index 0000000..31d9aa2
--- /dev/null
+++ b/src/app/api/anhaenge/[id]/route.ts
@@ -0,0 +1,61 @@
+import { AnhangFehler, anhangLesen } from "@/server/services/anhaenge";
+import { MandantFehler } from "@/server/mandant";
+import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
+import { istBild } from "@/lib/anhaenge";
+
+/**
+ * Einen Anhang ausliefern (#032).
+ *
+ * **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, nie was
+ * drinsteht (CLAUDE.md). Hier zählt das doppelt: eine Adresse, die eine Datei
+ * ohne Prüfung ausliefert, umgeht alles andere.
+ *
+ * `Content-Disposition: inline` nur für Bilder. Ein PDF wird **heruntergeladen**
+ * statt im Browser gerendert: ein PDF kann Skripte enthalten, und ein Anhang,
+ * den jemand aus einem fremden Lauf öffnet, soll nichts ausführen.
+ */
+export async function GET(
+ _anfrage: Request,
+ { params }: { params: Promise<{ id: string }> },
+): Promise {
+ let benutzer: { id: string; rolle: Parameters[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;
+ try {
+ const datei = await anhangLesen(id, benutzer);
+ // Der Dateiname steckt in einer Kopfzeile. `dateinameSaeubern` hat
+ // Steuerzeichen und Anführungszeichen schon beim Anlegen entfernt.
+ const anzeige = istBild(datei.medientyp) ? "inline" : "attachment";
+ return new Response(new Uint8Array(datei.inhalt), {
+ headers: {
+ "Content-Type": datei.medientyp,
+ "Content-Length": String(datei.groesse),
+ "Content-Disposition": `${anzeige}; filename="${datei.dateiname}"`,
+ // Privat: ein Anhang gehört einem Kunden, kein Zwischenspeicher darf ihn
+ // an den nächsten Betrachter weitergeben.
+ "Cache-Control": "private, max-age=0, must-revalidate",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ } catch (fehler) {
+ if (fehler instanceof MandantFehler) {
+ // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
+ // Unterscheidung wäre schon eine Auskunft über fremde Kunden.
+ return Response.json({ fehler: "Diesen Anhang gibt es nicht." }, { status: 404 });
+ }
+ if (fehler instanceof AnhangFehler) {
+ return Response.json({ fehler: fehler.message }, { status: 404 });
+ }
+ throw fehler;
+ }
+}
diff --git a/src/components/druck/lauf-beleg.tsx b/src/components/druck/lauf-beleg.tsx
index 6bd6030..dfcf1ce 100644
--- a/src/components/druck/lauf-beleg.tsx
+++ b/src/components/druck/lauf-beleg.tsx
@@ -1,6 +1,7 @@
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import { Wortmarke } from "@/components/wortmarke";
+import { groesseText, istBild } from "@/lib/anhaenge";
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
import type { KommentarZeile } from "@/server/queries/laeufe";
@@ -173,6 +174,51 @@ function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
);
}
+/**
+ * Anhaenge auf dem Ausdruck (#032).
+ *
+ * Bilder als Miniatur, ein PDF nur als Zeile — ein eingebettetes PDF liesse sich
+ * auf Papier ohnehin nicht ansehen. Die Datei kommt ueber ihre Adresse, nicht
+ * eingebettet: der Ausdruck entsteht im Browser des Benutzers, und dessen
+ * Sitzung laesst den Abruf ohnehin pruefen.
+ */
+function Anhaenge({ anhaenge }: { anhaenge: DruckSchritt["anhaenge"] }) {
+ if (anhaenge.length === 0) return null;
+ const bilder = anhaenge.filter((anhang) => istBild(anhang.medientyp));
+ const dateien = anhaenge.filter((anhang) => !istBild(anhang.medientyp));
+
+ return (
+
+ {bilder.length > 0 ? (
+
+ {bilder.map((anhang) => (
+
+ {/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
+ Rechteprüfung. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {anhang.dateiname}
+
) : null}
+
diff --git a/src/components/laeufe/schritt-anhaenge.tsx b/src/components/laeufe/schritt-anhaenge.tsx
new file mode 100644
index 0000000..d080428
--- /dev/null
+++ b/src/components/laeufe/schritt-anhaenge.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { useActionState, useRef, useState } from "react";
+import { Camera, FileText, Paperclip, X } from "lucide-react";
+import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge";
+import {
+ anhangEntfernenAktion,
+ anhangHochladenAktion,
+ type AktionsErgebnis,
+} from "@/server/actions/anhaenge";
+
+/**
+ * Anhaenge eines Schritts (#032).
+ *
+ * Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am
+ * Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler.
+ * Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser
+ * zeigt in beiden Faellen den Waehler.
+ *
+ * Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man
+ * gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener
+ * „Hochladen“-Knopf waere der Schritt, den man vergisst.
+ */
+
+export type SchrittAnhang = {
+ readonly id: string;
+ readonly dateiname: string;
+ readonly medientyp: string;
+ readonly groesse: number;
+ readonly erstelltVon: string | null;
+};
+
+export function SchrittAnhaenge({
+ laufId,
+ vorlagenSchrittId,
+ anhaenge,
+ lesend,
+}: {
+ laufId: string;
+ vorlagenSchrittId: string;
+ anhaenge: readonly SchrittAnhang[];
+ lesend: boolean;
+}) {
+ const [hochladen, hochladenAktion, laedt] = useActionState(
+ anhangHochladenAktion,
+ {},
+ );
+ const [entfernen, entfernenAktion] = useActionState(anhangEntfernenAktion, {});
+ const formular = useRef(null);
+ const [zuGross, setzeZuGross] = useState(null);
+
+ function gewaehlt(ereignis: React.ChangeEvent): void {
+ const datei = ereignis.target.files?.[0];
+ if (!datei) return;
+ /*
+ * Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB
+ * durch die Leitung, nur damit der Server sie ablehnt — am Handy im
+ * Mobilfunk ist das der Unterschied zwischen einer Meldung und einer
+ * Minute Warten. Dienst und Action pruefen es trotzdem noch einmal.
+ */
+ if (datei.size > maxGroesseBytes()) {
+ setzeZuGross(
+ `„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
+ `${groesseText(maxGroesseBytes())}.`,
+ );
+ ereignis.target.value = "";
+ return;
+ }
+ setzeZuGross(null);
+ formular.current?.requestSubmit();
+ }
+
+ const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler;
+
+ return (
+