feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

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

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

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

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

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-04 23:05:00 +02:00
co-authored by Claude Opus 5
parent 855164066c
commit 3cbd5026f8
25 changed files with 1989 additions and 47 deletions
+11
View File
@@ -19,6 +19,17 @@ APP_SECRET=
# #
# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`, # DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`,
# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die # `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). # Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT).
POSTGRES_USER=laufzettel POSTGRES_USER=laufzettel
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
+4
View File
@@ -28,6 +28,10 @@ blob-report/
# Release liegt es bei, so steht es im Auftrag. # Release liegt es bei, so steht es im Auftrag.
/docs/anleitung/*.pdf /docs/anleitung/*.pdf
# Hochgeladene Anhaenge (#032). Nutzdaten, nie Quellcode – in der Entwicklung
# landen sie unter `daten/anhaenge`, im Betrieb ausserhalb des Arbeitsbaums.
/daten/
# Werkzeuge # Werkzeuge
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
+15
View File
@@ -9,6 +9,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert ### 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 - Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
sich vorher nirgends speichern ließ (#020) sich vorher nirgends speichern ließ (#020)
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne - 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 ### 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 - **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 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 Schritte, die für die meisten „nicht zutreffend“ sind. Jeder Abschnitt kann jetzt sagen, für welche
+13
View File
@@ -23,6 +23,11 @@ readonly STANDALONE="$ZIEL/.next/standalone"
# Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt # Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt
# sauber und `git pull` findet nichts Fremdes vor. # sauber und `git pull` findet nichts Fremdes vor.
readonly HEIM="/var/cache/laufzettel" 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 ETC_VERZEICHNIS="/etc/laufzettel"
readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env" readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env"
readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service" 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 0755 "$ZIEL"
install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM" install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM"
install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS" 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 ----------------------- # ---------- Schritt 4: Geheimnisse und Umgebungsdatei -----------------------
@@ -365,6 +373,11 @@ DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCK
HOSTNAME="$bindAdresse" HOSTNAME="$bindAdresse"
PORT="$port" 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 # Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen. # Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
AUTH_LOCAL_ENABLED="true" AUTH_LOCAL_ENABLED="true"
+13
View File
@@ -99,9 +99,19 @@ services:
# Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er # Der Verzeichnis-Abgleich laeuft im selben Prozess (#016). Hier steht er
# an, weil dieser Dienst der einzige ist, der dauerhaft laeuft. # an, weil dieser Dienst der einzige ist, der dauerhaft laeuft.
ZEITPLAN_AKTIV: ${ZEITPLAN_AKTIV:-true} 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. # Standardmaessig nur auf localhost; davor gehoert ein Reverse Proxy.
ports: ports:
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000" - "${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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -130,3 +140,6 @@ networks:
volumes: volumes:
datenbank: datenbank:
# Die Dateien der Anhaenge (#032). Zusammen mit der Datenbank zu sichern –
# getrennt gesichert ergeben beide Haelften kein vollstaendiges Bild.
anhaenge:
+27
View File
@@ -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 entstanden und noch nicht abgeglichen ist — ihren **UPN** eintragen. Danach **Verknüpfen**. Ohne
das findet die Person in ihrer Historie später nichts wieder. 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 ## Abschließen
**Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht **Lauf abschließen** geht nur, wenn kein Pflichtschritt mehr offen ist. Erledigt und „nicht
+30
View File
@@ -77,6 +77,36 @@ Zurückholen wird protokolliert.
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein **Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar. 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 ## „Kein Laufzettel-Tag“ nach dem Scannen
**Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die **Ursache:** einer von drei Fällen, und Laufzettel unterscheidet sie absichtlich nicht — die
+123 -23
View File
@@ -11,12 +11,19 @@ ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
## Was gesichert werden muss ## Was gesichert werden muss
Zwei Dinge, und nur diese zwei: Drei Dinge, und nur diese drei:
| | Was | Wo | | | 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` | | **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, **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 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 Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung 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. **Was nicht gesichert werden muss:** Der Quellcode kommt aus Git, `node_modules` und `.next`
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
`.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 **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 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 **keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`. 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 ### Zurückspielen
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter 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 # 3. Abzug einspielen
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel 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 docker compose run --rm migrate pnpm exec prisma migrate deploy
# 5. Wieder anfahren # 6. Wieder anfahren
docker compose start web docker compose start web
curl -s http://127.0.0.1:3000/api/gesundheit 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 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: Und das Geheimnis, einmalig und in den Passwortmanager:
```bash ```bash
@@ -141,16 +185,29 @@ sudo -u postgres createdb -O laufzettel laufzettel
# 3. Abzug einspielen # 3. Abzug einspielen
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump 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 sudo bash /opt/laufzettel/deploy/install.sh
# 5. Wieder anfahren und nachsehen # 6. Wieder anfahren und nachsehen
sudo systemctl start laufzettel sudo systemctl start laufzettel
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
``` ```
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich Schritt 5 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: 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 ```bash
cd /opt/laufzettel cd /opt/laufzettel
@@ -170,11 +227,30 @@ After=postgresql.service
[Service] [Service]
Type=oneshot Type=oneshot
User=postgres # `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles # und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
# aelter als 30 Tage weg. # weiterhin als `postgres`, ueber `runuser`.
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump' User=root
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete 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`: `/etc/systemd/system/laufzettel-sicherung.timer`:
@@ -193,19 +269,22 @@ RandomizedDelaySec=15m
WantedBy=timers.target 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 ```bash
sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
sudo install -d -o postgres -g postgres -m 0700 /var/backups sudo install -d -o postgres -g postgres -m 0700 /var/backups
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now laufzettel-sicherung.timer 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 sudo systemctl list-timers laufzettel-sicherung.timer
``` ```
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist: Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-<Datum>.dump` und
`laufzettel-anhaenge-<Datum>.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
```bash `ANHAENGE_PFAD` zeigt woandershin als gedacht.
sudo systemctl start laufzettel-sicherung.service
ls -l /var/backups/
```
### Compose, als Cron-Eintrag ### 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 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. 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 ### Und dann weg vom Rechner
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und 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 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. 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 ## Umzug auf einen neuen Server
Derselbe Weg, nur mit einer Zwischenstation: 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. | | `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. | | `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 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. | | 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. | | 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. |
+89 -2
View File
@@ -24,12 +24,19 @@ als Unterpunkte mit Aufgabe und Dauer.
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min | | #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
| #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min | | #025 | Healthcheck gibt die rohe Datenbankfehlermeldung heraus | 25 min |
| #026 | Zwei Konventionen präzisieren | 20 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 | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | 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 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 (#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
davon steht geschrieben, statt verschwiegen zu werden. 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.
+56 -18
View File
@@ -1,10 +1,10 @@
# #032 M19 – Anhänge und Fotos an Schritten # #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) **Im Tracker:** [#52](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/52)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend, infra **Labels:** phase-2, backend, frontend, infra
**Geschätzt:** 10 h **Tatsächlich:** – **Geschätzt:** 10 h **Tatsächlich:** 3 h 05 min
## Ziel ## Ziel
@@ -14,7 +14,7 @@ können, am Handy direkt aus der Kamera.
## Zu entscheiden ## 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 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 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 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 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 Datenbank mitnimmt, verliert ab hier Daten — das ist genau die Art Zusage, die dieses Projekt
sonst teuer bezahlt hat. 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 Obergrenze (etwa 10 MB je Datei, 20 Dateien je Lauf) ist ehrlicher als eine Umwandlung, die
Metadaten verliert und eine Bildbibliothek ins Projekt zieht. Metadaten verliert und eine Bildbibliothek ins Projekt zieht.
## Aufgaben ## 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 `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 **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 Umgebung einstellbar; eine abgewiesene Datei nennt den Grund
- [ ] Am Handy direkt aus der Kamera (`capture="environment"`), am Rechner aus dem Dateiwähler - [x] 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] **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 dazu und keiner weg. `laufMussVeraenderbarSein` sitzt an genau einer Stelle — dort anschließen
- [ ] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile - [x] Druckansicht: Bilder als Miniaturen mit Dateinamen, PDF nur als Zeile
- [ ] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte, - [x] `docs/betrieb/sicherung.md`, `docker-compose.yml`, `deploy/install.sh`: Volume, Rechte,
Sicherung **und** Rückspielung Sicherung **und** Rückspielung
- [ ] Anleitung Kapitel 02 und 08 - [x] Anleitung Kapitel 02 und 08
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar - [x] Test: Anhang an einem Schritt hochladen, in der Abarbeitung und im Druck sichtbar
- [ ] Test: an einem archivierten Lauf wird das Hochladen abgewiesen - [x] Test: an einem archivierten Lauf wird das Hochladen abgewiesen
- [ ] Test: ein Anhang eines fremden Kunden ist über seine Adresse **nicht** abrufbar - [x] 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 - [x] 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: `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) `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 `<img>` holen
kann, und `src/app/api/anhaenge/[id]/route.ts` ruft selbst `rechtErforderlich("lesen")`, weil die
Hülle unter `(app)` Route Handler nicht abdeckt.
- **Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen.** `<ANHAENGE_PFAD>/<kundeId>/<laufId>/<id>.<endung>`.
Der Name vom Browser wird trotzdem gesäubert, weil er in einer Antwortkopfzeile landet.
- **Kein SVG**, obwohl es ein Bild ist: ein SVG ist ein Dokument, das Skripte ausführt. Darum eine
Liste erlaubter Medientypen und kein `image/*`.
- **Reihenfolge beim Anlegen:** prüfen, Datei schreiben (`flag: "wx"`), dann die Zeile — und bei
einem Fehler die Datei wieder weg. Eine Datei ohne Zeile findet niemand; eine Zeile ohne Datei ist
ein Anhang, der beim Öffnen fehlschlägt. Beim Entfernen umgekehrt: erst die Zeile, dann die Datei.
- **Zwei Felder `datei` in einem Formular — und `FormData.get` nahm das falsche.** „Foto“ und
„Datei“ stehen im selben Formular, abgeschickt wird immer beides, und das leere Feld kam zuerst:
wer „Datei“ wählte, bekam „Keine Datei gewählt“, während „Foto“ ging. Neunzehn grüne Dienst-Tests
sahen davon nichts, weil kein Dienst-Test zwei Felder schickt. Gefunden hat es erst die Klickprobe
am gebauten Stand. Die Regel liegt jetzt als reine Funktion `gewaehlteDatei()` in `src/lib`, und
`tests/e2e/anhaenge.spec.ts` lädt bewusst **über den zweiten Knopf** hoch. Gegenprobe gefahren:
mit `get` statt `getAll` wird der Ablauf rot.
- **`docs/betrieb/sicherung.md` war nicht nur unvollständig, sondern an einer Stelle falsch** — dort
stand „Kein Upload-Verzeichnis, keine erzeugten Dateien“. Die Datei ist neu geschrieben: drei
Dinge statt zwei, `tar` für beide Betriebswege, der `chown`-Fallstrick nach einer Rücksicherung als
`root`, ein Sicherungsskript für den systemd-Timer, das Datenbank und Anhänge im **selben** Lauf
mitnimmt, und die Gegenprobe über die Zahlen.
- **Sicherung und Rücksicherung wirklich gefahren**, nicht beschrieben: Abzug gezogen, Datenbank
verworfen und neu angelegt, Verzeichnis gelöscht, erst nur die Datenbank zurückgespielt — und da
meldete der Abruf genau den dokumentierten Satz „Die Datei zu … fehlt im Dateisystem“ —, dann die
Anhänge dazu, und die ausgelieferten Bytes gegen die gespeicherte `pruefsumme` geprüft. Beide
stimmten.
- **`tests/datenbankregeln.test.ts` musste nachziehen.** Er zählt die handgeschriebenen
Datenbankobjekte namentlich auf; die zwei neuen CHECK-Bedingungen und die beiden zusammengesetzten
Fremdschlüssel standen nicht drin, und der Test wurde dadurch rot — genau wie gewollt.
- **`/daten/` ist jetzt in `.gitignore`.** Ohne das läge das erste hochgeladene Foto im nächsten
Commit.
+2 -2
View File
@@ -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 | – | | #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 | | #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 | | #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 | – | | #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 | – | | #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 | – | | #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** |
@@ -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}$');
+46
View File
@@ -134,6 +134,7 @@ model Kunde {
benutzerbindungen BenutzerKunde[] benutzerbindungen BenutzerKunde[]
geraete Geraet[] geraete Geraet[]
laeufe Lauf[] laeufe Lauf[]
anhaenge Anhang[]
mitarbeiter Mitarbeiter[] mitarbeiter Mitarbeiter[]
protokolle Protokoll[] protokolle Protokoll[]
verzeichnisquellen Verzeichnisquelle[] verzeichnisquellen Verzeichnisquelle[]
@@ -172,6 +173,7 @@ model Benutzer {
protokolle Protokoll[] protokolle Protokoll[]
schrittergebnisse Schrittergebnis[] schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[]
@@index([aktiv]) @@index([aktiv])
@@index([rolle]) @@index([rolle])
@@ -635,7 +637,10 @@ model Lauf {
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
kommentare Kommentar[] kommentare Kommentar[]
protokolle Protokoll[] protokolle Protokoll[]
anhaenge Anhang[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
@@unique([id, kundeId])
@@index([kundeId, status]) @@index([kundeId, status])
@@index([geraetId, status]) @@index([geraetId, status])
@@index([mitarbeiterId]) @@index([mitarbeiterId])
@@ -665,11 +670,52 @@ model Schrittergebnis {
/// Codepfad darf annehmen, dass immer ein Mensch dahintersteht. /// Codepfad darf annehmen, dass immer ein Mensch dahintersteht.
aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict) aktualisiertVon Benutzer? @relation(fields: [aktualisiertVonId], references: [id], onDelete: Restrict)
kommentare Kommentar[] kommentare Kommentar[]
anhaenge Anhang[]
@@unique([laufId, vorlagenSchrittId]) @@unique([laufId, vorlagenSchrittId])
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
@@unique([id, laufId])
@@index([laufId, zustand]) @@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 { model Kommentar {
id String @id @default(cuid()) id String @id @default(cuid())
laufId String laufId String
+27
View File
@@ -15,10 +15,12 @@ import {
LaufMitAdKontoVerknuepfen, LaufMitAdKontoVerknuepfen,
LaufZurueckholen, LaufZurueckholen,
} from "@/components/laeufe/lauf-aktionen"; } from "@/components/laeufe/lauf-aktionen";
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
import { SchrittWert } from "@/components/laeufe/schritt-wert"; import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll"; import { protokollDesLaufs } from "@/server/queries/protokoll";
import { import {
kommentareDesLaufs, kommentareDesLaufs,
@@ -82,12 +84,14 @@ function Schritt({
lesend, lesend,
ausfuehrlich, ausfuehrlich,
kommentare, kommentare,
anhaenge,
}: { }: {
schritt: AbarbeitungsSchritt; schritt: AbarbeitungsSchritt;
laufId: string; laufId: string;
lesend: boolean; lesend: boolean;
ausfuehrlich: boolean; ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[]; kommentare: readonly KommentarZeile[];
anhaenge: readonly SchrittAnhang[];
}) { }) {
const vomTisch = const vomTisch =
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND; schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
@@ -197,6 +201,22 @@ function Schritt({
</ul> </ul>
) : null} ) : 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) ? (
<div className="mt-3">
<SchrittAnhaenge
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
anhaenge={anhaenge}
lesend={lesend || !schritt.ergebnisId}
/>
</div>
) : null}
{lesend || !schritt.ergebnisId ? null : ( {lesend || !schritt.ergebnisId ? null : (
<details className="mt-2"> <details className="mt-2">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9"> <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
@@ -220,12 +240,14 @@ function Abschnitt({
lesend, lesend,
ausfuehrlich, ausfuehrlich,
kommentare, kommentare,
anhaenge,
}: { }: {
abschnitt: AbarbeitungsAbschnitt; abschnitt: AbarbeitungsAbschnitt;
laufId: string; laufId: string;
lesend: boolean; lesend: boolean;
ausfuehrlich: boolean; ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[]; kommentare: readonly KommentarZeile[];
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
}) { }) {
const erledigt = abschnitt.schritte.filter( const erledigt = abschnitt.schritte.filter(
(schritt) => (schritt) =>
@@ -263,6 +285,7 @@ function Abschnitt({
lesend={lesend} lesend={lesend}
ausfuehrlich={ausfuehrlich} ausfuehrlich={ausfuehrlich}
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)} kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
/> />
))} ))}
</ul> </ul>
@@ -297,6 +320,9 @@ export default async function LaufSeite({
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die // Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
// Einträge dieses Laufs (#023). // Einträge dieses Laufs (#023).
const laufProtokoll = await protokollDesLaufs(id); 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 * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht. * jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -394,6 +420,7 @@ export default async function LaufSeite({
lesend={lesend} lesend={lesend}
ausfuehrlich={ausfuehrlich} ausfuehrlich={ausfuehrlich}
kommentare={kommentare} kommentare={kommentare}
anhaenge={anhaenge}
/> />
))} ))}
</div> </div>
+61
View File
@@ -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<Response> {
let benutzer: { id: string; rolle: Parameters<typeof anhangLesen>[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;
}
}
+47
View File
@@ -1,6 +1,7 @@
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react"; import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import { Wortmarke } from "@/components/wortmarke"; import { Wortmarke } from "@/components/wortmarke";
import { groesseText, istBild } from "@/lib/anhaenge";
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck"; import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
import type { KommentarZeile } from "@/server/queries/laeufe"; 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 (
<div className="mt-2 flex flex-col gap-1">
{bilder.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{bilder.map((anhang) => (
<li key={anhang.id} className="flex flex-col items-start gap-0.5">
{/* Ohne next/image: der Optimierer holte die Datei erneut und ohne
Rechteprüfung. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/anhaenge/${anhang.id}`}
alt={anhang.dateiname}
className="h-24 w-auto rounded border border-linie object-contain"
/>
<span className="max-w-32 truncate text-[10px] text-nebentext">{anhang.dateiname}</span>
</li>
))}
</ul>
) : null}
{dateien.length > 0 ? (
<ul className="flex flex-col gap-0.5">
{dateien.map((anhang) => (
<li key={anhang.id} className="text-xs text-nebentext">
Anhang: {anhang.dateiname} ({groesseText(anhang.groesse)})
</li>
))}
</ul>
) : null}
</div>
);
}
function Schritt({ schritt }: { schritt: DruckSchritt }) { function Schritt({ schritt }: { schritt: DruckSchritt }) {
const Symbol = ZUSTAND_SYMBOL[schritt.zustand]; const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
return ( return (
@@ -204,6 +250,7 @@ function Schritt({ schritt }: { schritt: DruckSchritt }) {
</p> </p>
) : null} ) : null}
<Anhaenge anhaenge={schritt.anhaenge} />
<Kommentare kommentare={schritt.kommentare} /> <Kommentare kommentare={schritt.kommentare} />
</div> </div>
+170
View File
@@ -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<AktionsErgebnis, FormData>(
anhangHochladenAktion,
{},
);
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
const formular = useRef<HTMLFormElement>(null);
const [zuGross, setzeZuGross] = useState<string | null>(null);
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): 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 (
<div className="flex flex-col gap-2">
{anhaenge.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{anhaenge.map((anhang) => (
<li key={anhang.id} className="flex flex-col gap-1">
<a
href={`/api/anhaenge/${anhang.id}`}
target="_blank"
rel="noreferrer"
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
}`}
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
>
{istBild(anhang.medientyp) ? (
// Das Bild selbst als Miniatur – ohne next/image, weil der
// Optimierer die Datei erneut und ohne Rechteprüfung holen
// würde.
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/anhaenge/${anhang.id}`}
alt={anhang.dateiname}
className="size-14 rounded object-cover"
/>
) : (
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
</span>
)}
<span className="flex min-w-0 max-w-40 flex-col text-xs">
<span className="truncate">{anhang.dateiname}</span>
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
</span>
</a>
{lesend ? null : (
<form action={entfernenAktion}>
<input type="hidden" name="anhangId" value={anhang.id} />
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
aria-label={`${anhang.dateiname} entfernen`}
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
>
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
entfernen
</button>
</form>
)}
</li>
))}
</ul>
) : null}
{lesend ? null : (
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
Foto
<input
type="file"
name="datei"
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
capture="environment"
onChange={gewaehlt}
className="sr-only"
/>
</label>
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
Datei
<input
type="file"
name="datei"
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
onChange={gewaehlt}
className="sr-only"
/>
</label>
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
</form>
)}
{meldung ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{meldung}
</p>
) : null}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Grenzen und Formen der Anhaenge (#032).
*
* Framework-frei, weil beide Seiten davon lesen: der Dienst prueft damit, und
* die Oberflaeche sagt damit **vorher**, was erlaubt ist. Stuenden die Grenzen
* nur im Dienst, waere jede Abweisung eine Ueberraschung.
*/
/**
* Was sich anhaengen laesst: Bilder und PDF.
*
* Bewusst eine Liste und kein Praefix-Vergleich wie `image/*`: ein Browser
* schickt auch `image/svg+xml`, und SVG ist ein Dokument mit Skripten, das ein
* Betrachter ausfuehrt. Ein Anhang, den jemand oeffnet, darf kein Angriffsweg
* sein.
*/
export const ERLAUBTE_MEDIENTYPEN = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
"application/pdf",
] as const;
export type Medientyp = (typeof ERLAUBTE_MEDIENTYPEN)[number];
/** Die Endung, unter der eine Datei auf der Platte liegt. */
export const ENDUNG_JE_TYP: Readonly<Record<Medientyp, string>> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"application/pdf": "pdf",
};
export function istErlaubterMedientyp(typ: string): typ is Medientyp {
return (ERLAUBTE_MEDIENTYPEN as readonly string[]).includes(typ);
}
/** Bilder werden als Miniatur gezeigt, ein PDF nur als Zeile. */
export function istBild(medientyp: string): boolean {
return medientyp !== "application/pdf" && istErlaubterMedientyp(medientyp);
}
const STANDARD_MAX_MB = 10;
const STANDARD_JE_LAUF = 20;
/**
* Hoechstgroesse je Datei in Bytes.
*
* Ein Handyfoto hat heute 3–6 MB. Zehn als Vorgabe lassen Luft, ohne dass eine
* einzelne Datei eine Sicherung sprengt. Keine serverseitige Verkleinerung:
* sie verliert Metadaten und zieht eine Bildbibliothek ins Projekt — eine
* ehrliche Obergrenze ist weniger Magie.
*/
export function maxGroesseBytes(): number {
const roh = Number.parseInt(process.env["ANHANG_MAX_MB"]?.trim() ?? "", 10);
const mb = Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_MB;
return mb * 1024 * 1024;
}
/** Hoechstzahl Anhaenge je Lauf. */
export function maxJeLauf(): number {
const roh = Number.parseInt(process.env["ANHAENGE_JE_LAUF"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_JE_LAUF;
}
/** Wo die Dateien liegen. Ohne Angabe ein Verzeichnis im Arbeitsbaum. */
export function anhaengePfad(): string {
return process.env["ANHAENGE_PFAD"]?.trim() || "./daten/anhaenge";
}
/**
* Die eine gewaehlte Datei aus mehreren Formularfeldern desselben Namens.
*
* Der Schritt hat **zwei** Felder `datei` in einem Formular: „Foto“ mit
* `capture="environment"` und „Datei“ mit dem Dateiwaehler. Abgeschickt wird
* immer das ganze Formular, also kommen beide Felder mit — das leere als
* `File` mit `size === 0`. `FormData.get("datei")` liefert das **erste**, und
* das war in #032 das leere: wer „Datei“ nutzte, bekam „Keine Datei gewählt“,
* waehrend „Foto“ ging. Gefunden erst in der Klickprobe am gebauten Stand, weil
* kein Dienst-Test zwei Felder schickt.
*
* Darum hier die Regel, und zwar als reine Funktion: das **erste nicht leere**
* Feld gewinnt, unabhaengig von der Reihenfolge im Formular.
*/
export function gewaehlteDatei(eintraege: readonly unknown[]): File | undefined {
return eintraege.find((eintrag): eintrag is File => eintrag instanceof File && eintrag.size > 0);
}
/** `4,2 MB` – fuer Meldungen und die Anzeige. */
export function groesseText(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
}
/**
* Ein Dateiname, der sich anzeigen laesst.
*
* Der Name vom Browser wird **nie** zum Pfad — der entsteht aus der `id`.
* Trotzdem wird er beschnitten und von Steuerzeichen befreit: er landet in einer
* Antwortkopfzeile und in der Oberflaeche.
*/
export function dateinameSaeubern(roh: string): string {
const ohnePfad = roh.split(/[\\/]/).pop() ?? "";
const sauber = ohnePfad.replace(/[\u0000-\u001f\u007f"]/g, "").trim();
return (sauber || "anhang").slice(0, 200);
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { gewaehlteDatei, groesseText, maxGroesseBytes } from "@/lib/anhaenge";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { LaufFehler } from "@/server/services/laeufe";
import { AnhangFehler, anhangEntfernen, anhangSpeichern } from "@/server/services/anhaenge";
/**
* Server Actions der Anhaenge (#032).
*
* Recht ist `laeufeBearbeiten` — wer einen Schritt abhaken darf, darf auch ein
* Foto dazu legen. Der Archiv-Schreibschutz sitzt im Dienst, an derselben
* Stelle wie fuer alles andere am Lauf.
*
* Die Datei kommt als `File` im Formular. Server Actions nehmen das von sich
* aus; ein eigener Upload-Endpunkt waere eine zweite Tuer mit eigener
* Rechtepruefung.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung.";
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof AnhangFehler || fehler instanceof LaufFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof MandantFehler) return { fehler: fehler.message };
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
console.error(
`${neutral}:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
);
return { fehler: neutral };
}
export async function anhangHochladenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
const eingabe = z.object({ laufId: z.string().min(1), vorlagenSchrittId: z.string().min(1) }).safeParse({
laufId: formular.get("laufId"),
vorlagenSchrittId: formular.get("vorlagenSchrittId"),
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
// `getAll`, nicht `get`: das Formular hat zwei Felder `datei` – siehe
// `gewaehlteDatei`.
const datei = gewaehlteDatei(formular.getAll("datei"));
if (!datei) {
return { fehler: "Keine Datei gewählt." };
}
/*
* Die Groesse **vor** dem Lesen pruefen. `arrayBuffer()` zieht die ganze
* Datei in den Speicher; bei einer 500-MB-Datei waere die Pruefung danach zu
* spaet. Der Dienst prueft es noch einmal — er ist auch ohne diese Action
* erreichbar.
*/
const grenze = maxGroesseBytes();
if (datei.size > grenze) {
return {
fehler:
`Die Datei ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
`${groesseText(grenze)} je Datei.`,
};
}
await anhangSpeichern(
{
laufId: eingabe.data.laufId,
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
dateiname: datei.name,
medientyp: datei.type,
inhalt: new Uint8Array(await datei.arrayBuffer()),
},
{ id: benutzer.id, rolle: benutzer.rolle },
);
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
return { erfolg: "Angehängt." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Anhang konnte nicht gespeichert werden.");
}
}
export async function anhangEntfernenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("laeufeBearbeiten");
const eingabe = z
.object({ anhangId: z.string().min(1), laufId: z.string().min(1) })
.safeParse({ anhangId: formular.get("anhangId"), laufId: formular.get("laufId") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
await anhangEntfernen(eingabe.data.anhangId, { id: benutzer.id, rolle: benutzer.rolle });
revalidatePath(`/laeufe/${eingabe.data.laufId}`);
return { erfolg: "Entfernt." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Anhang konnte nicht entfernt werden.");
}
}
+19
View File
@@ -1,6 +1,7 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { anhaengeDesLaufs } from "@/server/services/anhaenge";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe"; import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
@@ -87,6 +88,13 @@ export type DruckSchritt = {
readonly bearbeitetVon: string | null; readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null; readonly bearbeitetAm: Date | null;
readonly kommentare: readonly KommentarZeile[]; readonly kommentare: readonly KommentarZeile[];
/** Anhaenge an diesem Schritt (#032). Bilder als Miniatur, PDF als Zeile. */
readonly anhaenge: readonly {
readonly id: string;
readonly dateiname: string;
readonly medientyp: string;
readonly groesse: number;
}[];
}; };
export type DruckAbschnitt = { export type DruckAbschnitt = {
@@ -216,6 +224,9 @@ export async function laufZumDrucken(
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen // Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
// kann. // kann.
const alleKommentare = await kommentareDesLaufs(laufId, benutzer); const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
// nicht in sich (#032).
const alleAnhaenge = await anhaengeDesLaufs(laufId);
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis])); const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
let nummer = 0; let nummer = 0;
@@ -264,6 +275,14 @@ export async function laufZumDrucken(
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"), ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null), bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id), kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
anhaenge: alleAnhaenge
.filter((anhang) => anhang.schrittergebnisId === ergebnis?.id)
.map((anhang) => ({
id: anhang.id,
dateiname: anhang.dateiname,
medientyp: anhang.medientyp,
groesse: anhang.groesse,
})),
}; };
}); });
+262
View File
@@ -0,0 +1,262 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import {
ENDUNG_JE_TYP,
anhaengePfad,
dateinameSaeubern,
groesseText,
istErlaubterMedientyp,
maxGroesseBytes,
maxJeLauf,
type Medientyp,
} from "@/lib/anhaenge";
import { laufZumSchreibenHolen } from "@/server/services/laeufe";
import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Anhaenge und Fotos an Schritten (#032).
*
* Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad
* entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname
* von aussen darf keinen Pfad bestimmen.
*
* Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die
* Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` —
* dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren.
*/
export class AnhangFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "AnhangFehler";
}
}
/** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */
function dateiPfad(anhang: {
readonly id: string;
readonly kundeId: string;
readonly laufId: string;
readonly medientyp: string;
}): string {
const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin";
return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`);
}
export type AnhangEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly dateiname: string;
readonly medientyp: string;
readonly inhalt: Uint8Array;
};
/**
* Legt einen Anhang an einem Schritt ab.
*
* Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann
* die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder
* entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt
* waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen
* fehlschlaegt.
*/
export async function anhangSpeichern(
eingabe: AnhangEingabe,
benutzer: HandelnderBenutzer,
): Promise<{ readonly id: string }> {
if (!istErlaubterMedientyp(eingabe.medientyp)) {
throw new AnhangFehler(
`Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` +
"Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.",
);
}
if (eingabe.inhalt.byteLength === 0) {
throw new AnhangFehler("Die Datei ist leer.");
}
const grenze = maxGroesseBytes();
if (eingabe.inhalt.byteLength > grenze) {
throw new AnhangFehler(
`Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` +
`${groesseText(grenze)} je Datei.`,
);
}
const vorbereitet = await prisma.$transaction(async (tx) => {
// Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle.
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const ergebnis = await tx.schrittergebnis.findUnique({
where: {
laufId_vorlagenSchrittId: {
laufId: lauf.id,
vorlagenSchrittId: eingabe.vorlagenSchrittId,
},
},
select: { id: true },
});
if (!ergebnis) {
throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht.");
}
const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } });
const hoechstens = maxJeLauf();
if (vorhanden >= hoechstens) {
throw new AnhangFehler(
`An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` +
"nimmt Laufzettel nicht — erst eine entfernen.",
);
}
return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id };
});
const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex");
const id = `anh_${createHash("sha256")
.update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`)
.digest("hex")
.slice(0, 24)}`;
const pfad = dateiPfad({
id,
kundeId: vorbereitet.kundeId,
laufId: vorbereitet.laufId,
medientyp: eingabe.medientyp,
});
await mkdir(join(pfad, ".."), { recursive: true });
await writeFile(pfad, eingabe.inhalt, { flag: "wx" });
try {
await prisma.anhang.create({
data: {
id,
schrittergebnisId: vorbereitet.schrittergebnisId,
laufId: vorbereitet.laufId,
kundeId: vorbereitet.kundeId,
dateiname: dateinameSaeubern(eingabe.dateiname),
medientyp: eingabe.medientyp,
groesse: eingabe.inhalt.byteLength,
pruefsumme,
erstelltVonId: benutzer.id,
},
});
} catch (fehler) {
// Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr.
await rm(pfad, { force: true });
throw fehler;
}
return { id };
}
/**
* Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze.
*
* **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die
* ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht.
*/
export async function anhangLesen(
anhangId: string,
benutzer: HandelnderBenutzer,
): Promise<{
readonly inhalt: Buffer;
readonly medientyp: string;
readonly dateiname: string;
readonly groesse: number;
}> {
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
await kundeZugriffPruefen(anhang.kundeId, benutzer);
const pfad = dateiPfad(anhang);
let inhalt: Buffer;
try {
inhalt = await readFile(pfad);
} catch {
/*
* Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung,
* die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das
* auch, statt „nicht gefunden“ zu behaupten.
*/
throw new AnhangFehler(
`Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` +
"eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.",
);
}
return {
inhalt,
medientyp: anhang.medientyp,
dateiname: anhang.dateiname,
groesse: anhang.groesse,
};
}
/**
* Entfernt einen Anhang.
*
* Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar
* und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen,
* zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist.
*/
export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise<void> {
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
await prisma.$transaction(async (tx) => {
// Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf
// kommt kein Anhang weg.
await laufZumSchreibenHolen(tx, anhang.laufId, benutzer);
await tx.anhang.delete({ where: { id: anhangId } });
});
await rm(dateiPfad(anhang), { force: true });
}
/** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */
export async function anhaengeDesLaufs(laufId: string): Promise<
readonly {
readonly id: string;
readonly schrittergebnisId: string;
readonly dateiname: string;
readonly medientyp: string;
readonly groesse: number;
readonly erstelltAm: Date;
readonly erstelltVon: string | null;
}[]
> {
const zeilen = await prisma.anhang.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
select: {
id: true,
schrittergebnisId: true,
dateiname: true,
medientyp: true,
groesse: true,
erstelltAm: true,
erstelltVon: { select: { name: true, email: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
schrittergebnisId: zeile.schrittergebnisId,
dateiname: zeile.dateiname,
medientyp: zeile.medientyp,
groesse: zeile.groesse,
erstelltAm: zeile.erstelltAm,
erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null,
}));
}
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
export function anhangPfad(anhang: {
readonly id: string;
readonly kundeId: string;
readonly laufId: string;
readonly medientyp: string;
}): string {
return dateiPfad(anhang);
}
export type { Medientyp };
+486
View File
@@ -0,0 +1,486 @@
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
import {
dateinameSaeubern,
gewaehlteDatei,
groesseText,
istBild,
istErlaubterMedientyp,
} from "@/lib/anhaenge";
import {
AnhangFehler,
anhaengeDesLaufs,
anhangEntfernen,
anhangLesen,
anhangPfad,
anhangSpeichern,
} from "@/server/services/anhaenge";
import { MandantFehler } from "@/server/mandant";
import { LaufFehler } from "@/server/services/laeufe";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Anhaenge und Fotos an Schritten (#032).
*
* Vier Zusagen tragen das Ganze: der Archiv-Schreibschutz gilt auch hier, ein
* fremder Kunde kommt nicht heran, die Grenzen greifen mit einer Begruendung,
* und eine Datei ohne Zeile bleibt nicht liegen.
*
* Die Dateien landen in einem Wegwerf-Verzeichnis — `ANHAENGE_PFAD` wird fuer
* diese Datei umgebogen und danach wieder weggeraeumt.
*/
const VORHER = process.env["ANHAENGE_PFAD"];
let verzeichnis = "";
beforeAll(async () => {
verzeichnis = await mkdtemp(join(tmpdir(), "laufzettel-anhaenge-"));
process.env["ANHAENGE_PFAD"] = verzeichnis;
});
afterAll(async () => {
if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"];
else process.env["ANHAENGE_PFAD"] = VORHER;
if (verzeichnis) await rm(verzeichnis, { recursive: true, force: true });
});
describe("Formen und Grenzen", () => {
it("erlaubt Bilder und PDF, aber kein SVG", () => {
// SVG ist ein Dokument mit Skripten, das ein Betrachter ausführt. Ein
// Anhang, den jemand öffnet, darf kein Angriffsweg sein.
expect(istErlaubterMedientyp("image/jpeg")).toBe(true);
expect(istErlaubterMedientyp("application/pdf")).toBe(true);
expect(istErlaubterMedientyp("image/svg+xml")).toBe(false);
expect(istErlaubterMedientyp("text/html")).toBe(false);
});
it("zeigt nur Bilder als Miniatur", () => {
expect(istBild("image/png")).toBe(true);
expect(istBild("application/pdf")).toBe(false);
});
it("macht aus einem Pfad einen Dateinamen und wirft Steuerzeichen weg", () => {
// Der Name wird nie zum Pfad – trotzdem landet er in einer Kopfzeile.
expect(dateinameSaeubern("../../etc/passwd")).toBe("passwd");
expect(dateinameSaeubern("C:\\Users\\x\\foto.jpg")).toBe("foto.jpg");
expect(dateinameSaeubern('fo"to\u0000.jpg')).toBe("foto.jpg");
expect(dateinameSaeubern(" ")).toBe("anhang");
expect(dateinameSaeubern("x".repeat(300))).toHaveLength(200);
});
it("schreibt Größen deutsch", () => {
expect(groesseText(512)).toBe("512 B");
expect(groesseText(2048)).toBe("2 KB");
expect(groesseText(3 * 1024 * 1024 + 100_000)).toBe("3,1 MB");
});
it("nimmt aus zwei Feldern „datei“ das nicht leere", () => {
/*
* Der Schritt hat zwei Felder „datei“ – „Foto“ und „Datei“ – in einem
* Formular, also kommt immer auch das leere mit. `FormData.get` lieferte
* das erste, und wer „Datei“ wählte, bekam „Keine Datei gewählt“. Gefunden
* in der Klickprobe am gebauten Stand; kein Dienst-Test schickt zwei
* Felder.
*/
const leer = new File([], "");
const echt = new File([new Uint8Array([1, 2, 3])], "foto.png", { type: "image/png" });
expect(gewaehlteDatei([leer, echt])?.name).toBe("foto.png");
expect(gewaehlteDatei([echt, leer])?.name).toBe("foto.png");
expect(gewaehlteDatei([leer, leer])).toBeUndefined();
expect(gewaehlteDatei(["nicht einmal eine Datei", null])).toBeUndefined();
});
});
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Typenschild fotografieren", position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
const lauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id },
});
const ergebnis = await testPrisma.schrittergebnis.create({
data: { laufId: lauf.id, vorlagenSchrittId: schritt.id },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
laufId: lauf.id,
schrittId: schritt.id,
ergebnisId: ergebnis.id,
};
}
/** Ein winziges, gültiges PNG. */
const PNG = Buffer.from(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" +
"0000000a49444154789c6300010000050001" +
"0d0a2db40000000049454e44ae426082",
"hex",
);
describe.runIf(datenbankVerfuegbar)("Anhänge", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("legt eine Datei ab und schreibt die Metadaten", async () => {
const { admin, laufId, schrittId, ergebnisId, kunde } = await aufbau();
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "typenschild.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } });
expect(zeile).toMatchObject({
schrittergebnisId: ergebnisId,
laufId,
kundeId: kunde.id,
dateiname: "typenschild.png",
medientyp: "image/png",
groesse: PNG.byteLength,
erstelltVonId: admin.id,
});
// SHA-256 als Hex – eine CHECK-Bedingung hält die Form fest.
expect(zeile.pruefsumme).toMatch(/^[0-9a-f]{64}$/);
// Die Datei liegt wirklich da, und zwar unter einem Pfad aus der Kennung.
const pfad = anhangPfad(zeile);
expect(pfad).toContain(kunde.id);
expect(pfad).toContain(laufId);
expect(pfad).not.toContain("typenschild");
expect((await stat(pfad)).size).toBe(PNG.byteLength);
expect(await readFile(pfad)).toEqual(PNG);
});
it("liest den Inhalt wieder zurück", async () => {
const { admin, laufId, schrittId } = await aufbau();
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const datei = await anhangLesen(id, admin);
expect(datei.inhalt).toEqual(PNG);
expect(datei.medientyp).toBe("image/png");
expect(datei.dateiname).toBe("a.png");
});
it("weist einen fremden Medientyp mit Begründung ab", async () => {
const { admin, laufId, schrittId } = await aufbau();
await expect(
anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "boese.svg",
medientyp: "image/svg+xml",
inhalt: new Uint8Array(PNG),
},
admin,
),
).rejects.toThrow(/lassen sich nicht anhängen/);
});
it("weist eine zu große Datei mit Größen in der Meldung ab", async () => {
const vorher = process.env["ANHANG_MAX_MB"];
process.env["ANHANG_MAX_MB"] = "1";
try {
const { admin, laufId, schrittId } = await aufbau();
await expect(
anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "gross.png",
medientyp: "image/png",
inhalt: new Uint8Array(2 * 1024 * 1024),
},
admin,
),
).rejects.toThrow(/2,0 MB groß.*höchstens 1,0 MB/s);
} finally {
if (vorher === undefined) delete process.env["ANHANG_MAX_MB"];
else process.env["ANHANG_MAX_MB"] = vorher;
}
});
it("weist eine leere Datei ab", async () => {
const { admin, laufId, schrittId } = await aufbau();
await expect(
anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "leer.png",
medientyp: "image/png",
inhalt: new Uint8Array(0),
},
admin,
),
).rejects.toThrow(/leer/);
});
it("begrenzt die Anzahl je Lauf", async () => {
const vorher = process.env["ANHAENGE_JE_LAUF"];
process.env["ANHAENGE_JE_LAUF"] = "2";
try {
const { admin, laufId, schrittId } = await aufbau();
const eingabe = {
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
};
await anhangSpeichern(eingabe, admin);
await anhangSpeichern(eingabe, admin);
await expect(anhangSpeichern(eingabe, admin)).rejects.toThrow(/Mehr als 2 je Lauf/);
} finally {
if (vorher === undefined) delete process.env["ANHAENGE_JE_LAUF"];
else process.env["ANHAENGE_JE_LAUF"] = vorher;
}
});
it("weist an einem archivierten Lauf ab – Hochladen und Entfernen", async () => {
/*
* Der Archiv-Schreibschutz sitzt an genau einer Stelle
* (`laufZumSchreibenHolen` → `laufMussVeraenderbarSein`). Dass er auch hier
* greift, ist das Kriterium aus dem Issue.
*/
const { admin, laufId, schrittId } = await aufbau();
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
// Eine CHECK-Bedingung verlangt zu einem archivierten Lauf auch einen
// Abschlusszeitpunkt: archiviert wird nur, was fertig ist.
await testPrisma.lauf.update({
where: { id: laufId },
data: {
status: LaufStatus.ARCHIVIERT,
gestartetAm: new Date(),
abgeschlossenAm: new Date(),
archiviertAm: new Date(),
},
});
await expect(
anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "b.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
),
).rejects.toThrow(LaufFehler);
await expect(anhangEntfernen(id, admin)).rejects.toThrow(/schreibgeschützt/);
// Lesen bleibt erlaubt: ein Foto vom Typenschild ist Teil des Belegs.
expect((await anhangLesen(id, admin)).inhalt).toEqual(PNG);
});
it("weist einen Schritt ab, der nicht zu diesem Lauf gehört", async () => {
const { admin, laufId } = await aufbau();
await expect(
anhangSpeichern(
{
laufId,
vorlagenSchrittId: "gibt-es-nicht",
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
),
).rejects.toThrow(/Schritt gibt es in diesem Lauf nicht/);
});
it("entfernt Zeile und Datei zusammen", async () => {
const { admin, laufId, schrittId } = await aufbau();
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } });
const pfad = anhangPfad(zeile);
await anhangEntfernen(id, admin);
expect(await testPrisma.anhang.findUnique({ where: { id } })).toBeNull();
await expect(stat(pfad)).rejects.toThrow();
});
it("sagt, wenn die Datei fehlt – und nennt die Sicherung als Grund", async () => {
// Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat.
const { admin, laufId, schrittId } = await aufbau();
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } });
await rm(anhangPfad(zeile));
await expect(anhangLesen(id, admin)).rejects.toThrow(/sicherung\.md/);
});
it("zählt die Anhänge eines Laufs mit Urheber", async () => {
const { admin, laufId, schrittId, ergebnisId } = await aufbau();
await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "eins.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const liste = await anhaengeDesLaufs(laufId);
expect(liste).toHaveLength(1);
expect(liste[0]).toMatchObject({
schrittergebnisId: ergebnisId,
dateiname: "eins.png",
erstelltVon: "Admin",
});
});
it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => {
const { laufId, schrittId, ergebnisId } = await aufbau();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
expect(schrittId).toBeTruthy();
// Ein Anhang mit fremdem Kunden, an der Service-Schicht vorbei: der
// zusammengesetzte Fremdschlüssel weist das ab.
await expect(
testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnisId,
laufId,
kundeId: fremd.id,
dateiname: "x.png",
medientyp: "image/png",
groesse: 10,
pruefsumme: "a".repeat(64),
},
}),
).rejects.toThrow(/Anhang_lauf_gleicher_kunde/);
});
it("weist eine unmögliche Größe und eine falsche Prüfsumme ab", async () => {
const { laufId, ergebnisId, kunde } = await aufbau();
const basis = {
schrittergebnisId: ergebnisId,
laufId,
kundeId: kunde.id,
dateiname: "x.png",
medientyp: "image/png",
};
await expect(
testPrisma.anhang.create({ data: { ...basis, groesse: 0, pruefsumme: "a".repeat(64) } }),
).rejects.toThrow(/Anhang_groesse_positiv/);
await expect(
testPrisma.anhang.create({ data: { ...basis, groesse: 10, pruefsumme: "keine pruefsumme" } }),
).rejects.toThrow(/Anhang_pruefsumme_form/);
});
it("verweigert den Abruf für einen auf andere Kunden beschränkten Benutzer", async () => {
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const { admin, laufId, schrittId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const { id } = await anhangSpeichern(
{
laufId,
vorlagenSchrittId: schrittId,
dateiname: "a.png",
medientyp: "image/png",
inhalt: new Uint8Array(PNG),
},
admin,
);
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: fremd.id },
});
await expect(anhangLesen(id, { id: techniker.id, rolle: techniker.rolle })).rejects.toThrow(
MandantFehler,
);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("wirft bei einem Anhang, den es nicht gibt, einen eigenen Fehler", async () => {
const { admin } = await aufbau();
await expect(anhangLesen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler);
await expect(anhangEntfernen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler);
});
});
+26
View File
@@ -49,6 +49,32 @@ describe("Betriebsstand: docker-compose.yml", () => {
expect(probe).toEqual(["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]); expect(probe).toEqual(["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]);
}); });
it("hat ein Volume für die Anhänge – sonst sind sie beim nächsten Neuaufbau weg", () => {
/*
* Die Dateien der Anhaenge liegen im Dateisystem, in der Datenbank stehen
* nur die Metadaten (#032). Ohne Volume lebten sie im Container: ein
* `docker compose up --build` waere Datenverlust, und die Zeilen zeigten auf
* Dateien, die es nicht mehr gibt.
*/
const web = stand.services["web"] as unknown as {
volumes?: string[];
environment?: Record<string, string>;
};
expect(web.volumes).toContain("anhaenge:/daten/anhaenge");
expect(web.environment?.["ANHAENGE_PFAD"]).toBe("/daten/anhaenge");
expect(Object.keys((stand as unknown as { volumes: Record<string, unknown> }).volumes)).toContain(
"anhaenge",
);
});
it("nennt das Anhang-Volume in der Sicherungsanleitung", () => {
// Eine Sicherung, die nur `pg_dump` macht, verliert ab #032 Daten. Dass die
// Anleitung das sagt, ist Teil der Zusage – nicht nur das Volume selbst.
const sicherung = readFileSync("docs/betrieb/sicherung.md", "utf8");
expect(sicherung).toContain("anhaenge");
expect(sicherung).toMatch(/ANHAENGE_PFAD/);
});
it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => { it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => {
const roh = readFileSync("docker-compose.yml", "utf8"); const roh = readFileSync("docker-compose.yml", "utf8");
expect(roh).toContain("${APP_SECRET:?"); expect(roh).toContain("${APP_SECRET:?");
+10 -2
View File
@@ -23,10 +23,16 @@ async function namen(abfrage: string): Promise<string[]> {
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => { describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => { it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen. // Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen — und ein
// Anhang an einem Schritt hängen, der zu einem anderen Lauf gehört.
expect( expect(
await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`), await namen(
`SELECT conname AS name FROM pg_constraint
WHERE conname LIKE '%gleicher_kunde' OR conname LIKE '%gleicher_lauf'`,
),
).toEqual([ ).toEqual([
"Anhang_ergebnis_gleicher_lauf",
"Anhang_lauf_gleicher_kunde",
"Geraet_besitzer_gleicher_kunde", "Geraet_besitzer_gleicher_kunde",
"Lauf_abteilung_gleicher_kunde", "Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde", "Lauf_geraet_gleicher_kunde",
@@ -80,6 +86,8 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
); );
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt. // Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
expect(vorhanden).toEqual([ expect(vorhanden).toEqual([
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
"Einstellung_einzelne_zeile", "Einstellung_einzelne_zeile",
"Geraet_registriert_braucht_namen", "Geraet_registriert_braucht_namen",
"Geraet_tagcode_form", "Geraet_tagcode_form",
+151
View File
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { prisma } from "@/lib/db";
import { anhangPfad } from "@/server/services/anhaenge";
import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Fotos und Dateien an einem Schritt, durch die Oberflaeche (#032).
*
* Dieser Ablauf gibt es, weil der Dienst-Test ihn nicht ersetzen konnte. Der
* Schritt hat **zwei** Felder `datei` in einem Formular — „Foto“ und „Datei“ —,
* und `FormData.get` lieferte das erste, also das leere: wer „Datei“ waehlte,
* bekam „Keine Datei gewählt“, waehrend „Foto“ ging. Neunzehn gruene
* Dienst-Tests sahen davon nichts, weil keiner zwei Felder schickt. Gefunden hat
* es die Klickprobe am gebauten Stand; festgehalten wird es hier.
*
* Geprueft wird deshalb bewusst **ueber den zweiten Knopf** und danach, ob es
* wirklich steht: Neuladen, Zeile in der Datenbank, Datei auf der Platte, und
* die Pruefsumme der ausgelieferten Bytes gegen die gespeicherte.
*/
const STEMPEL = Date.now();
/* Ein echtes 2x2-PNG. Eine leere Datei wuerde der Dienst zu Recht abweisen. */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAACZgbYnAAAAEUlEQVR4nGP8z8DAwMDEwAAAEAAHAAo7/UkAAAAASUVORK5CYII=",
"base64",
);
/* Der zweite Ablauf arbeitet am Lauf des ersten weiter – also der Reihe nach. */
test.describe.serial("Anhänge an einem Schritt", () => {
let laufId = "";
test("über „Datei“ hochladen, neu laden, entfernen", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page, SEED_VORLAGE);
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
await kundeWaehlen(page, geraet.kundeId);
await page.selectOption('select[name="geraetId"]', geraet.id);
await page.locator("details summary", { hasText: "Titel, Ticket" }).click();
await page.getByLabel("Titel", { exact: true }).fill(`ZZ E2E Anhang ${STEMPEL}`);
await variablenFuellen(page);
await page.getByRole("button", { name: "Lauf anlegen" }).click();
await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
laufId = new URL(page.url()).pathname.split("/").pop() ?? "";
/*
* Beginnen, wie der Techniker es taete. Anhaengen ginge auch am offenen
* Lauf — `lesend` haengt am Archiv und am Recht, nicht am Status —, aber
* genau so wird die Oberflaeche benutzt.
*/
await page.getByRole("button", { name: "Lauf beginnen" }).click();
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
for (const kopf of await page.locator("button[aria-expanded]").all()) {
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
}
/*
* Feld 0 ist „Foto“, Feld 1 ist „Datei“ – beide heissen `datei` und stehen
* im selben Formular. Genau der zweite Weg war kaputt, also geht der Ablauf
* ueber ihn.
*/
const felder = page.locator('input[name="datei"]');
expect(await felder.count()).toBeGreaterThan(1);
await felder.nth(1).setInputFiles({ name: "serverraum.png", mimeType: "image/png", buffer: PNG });
const miniatur = page.locator('img[src^="/api/anhaenge/"]').first();
await expect(miniatur).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("serverraum.png").first()).toBeVisible();
// Neu laden: steht es wirklich, oder nur im Zustand der Seite?
await page.reload();
await expect(page.locator('img[src^="/api/anhaenge/"]').first()).toBeVisible({ timeout: 30_000 });
const zeile = await prisma.anhang.findFirstOrThrow({
where: { laufId },
select: {
id: true,
kundeId: true,
laufId: true,
dateiname: true,
medientyp: true,
groesse: true,
pruefsumme: true,
},
});
expect(zeile).toMatchObject({
dateiname: "serverraum.png",
medientyp: "image/png",
groesse: PNG.byteLength,
pruefsumme: createHash("sha256").update(PNG).digest("hex"),
});
// Und auf der Platte, unter dem Pfad aus der Kennung.
const pfad = anhangPfad(zeile);
expect((await stat(pfad)).size).toBe(PNG.byteLength);
// Die Auslieferung prueft Recht und Mandant selbst – und liefert die Bytes,
// die hereingingen.
const antwort = await page.request.get(`/api/anhaenge/${zeile.id}`);
expect(antwort.status()).toBe(200);
expect(antwort.headers()["content-type"]).toBe("image/png");
expect(antwort.headers()["x-content-type-options"]).toBe("nosniff");
expect(
createHash("sha256")
.update(await antwort.body())
.digest("hex"),
).toBe(zeile.pruefsumme);
// Entfernen nimmt Zeile **und** Datei.
await page.getByRole("button", { name: "serverraum.png entfernen" }).click();
await expect(page.locator('img[src^="/api/anhaenge/"]')).toHaveCount(0, { timeout: 30_000 });
expect(await prisma.anhang.count({ where: { laufId } })).toBe(0);
await expect(stat(pfad)).rejects.toThrow();
});
test("weist ein SVG ab, auch wenn es am Dateiwähler vorbei kommt", async ({ page }) => {
test.slow();
await anmelden(page);
expect(laufId).not.toBe("");
await page.goto(`/laeufe/${laufId}`);
for (const kopf of await page.locator("button[aria-expanded]").all()) {
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
}
await page
.locator('input[name="datei"]')
.nth(1)
.setInputFiles({
name: "boeses.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
});
/*
* `accept` haelt im Dateiwaehler nur die Finger ab. Dass ein SVG nicht
* hereinkommt, muss der Server sagen – ein SVG ist ein Dokument mit
* Skripten, das ein Betrachter ausfuehrt.
*/
await expect(page.getByText(/lassen sich nicht anhängen/)).toBeVisible({ timeout: 30_000 });
expect(await prisma.anhang.count({ where: { laufId } })).toBe(0);
});
});