From 3cbd5026f8f1a89db0f6e688d0cc29ddf95e7c6b Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Sun, 4 Oct 2026 23:05:00 +0200
Subject: [PATCH] feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.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 (
+