2 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 4aa79c93ff feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
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
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:36:10 +02:00
Florian WildeandClaude Opus 5 3cbd5026f8 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>
2026-10-04 23:05:00 +02:00
38 changed files with 3626 additions and 128 deletions
+28
View File
@@ -19,6 +19,34 @@ 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
#
# **Wer das anhebt, hebt `client_max_body_size` im Reverse Proxy mit an.** Sonst
# weist nginx die Datei mit 413 ab, bevor die Anwendung ihre eigene, freundliche
# Meldung zeigen kann — siehe docs/betrieb/reverse-proxy.md.
# ---- Live-Aktualisierung (#033) --------------------------------------------
# Die Abarbeitungsansicht haelt einen Ereignisstrom offen, statt alle zehn
# Sekunden zu fragen. Beide Werte brauchen im Normalfall niemand anzufassen.
#
# Abstand der Herzschlaege in Millisekunden. Muss **unter** der Leerlaufzeit des
# Reverse Proxy liegen (nginx und Caddy: 60 s), sonst schliesst der die stille
# Verbindung.
SSE_HERZSCHLAG_MS=25000
# Hoechstzahl gleichzeitig offener Stroeme je Instanz. Ist sie erreicht, fallen
# weitere Laschen von sich aus auf das Polling zurueck — die Seite bleibt
# bedienbar, nur nicht mehr sofort aktuell.
SSE_MAX_STROEME=200
# 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
+35
View File
@@ -9,6 +9,25 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert ### Geändert
- **Die Abarbeitungsansicht aktualisiert sich jetzt sofort statt alle zehn Sekunden.** Hakt ein
Kollege einen Schritt ab, schreibt einen Kommentar, hängt ein Foto an oder ändert den Titel,
erscheint das beim anderen in weniger als einer halben Sekunde — gemessen 485 ms, vorher bis zu
zehn Sekunden. Dahinter stehen vier Datenbank-Trigger mit `NOTIFY` und ein Ereignisstrom; das
funktioniert auch mit mehreren Instanzen der Anwendung, ohne einen zweiten Dienst. Zwanzig offene
Laschen kosten jetzt nichts mehr, statt zwanzig Abfragen je zehn Sekunden.
**Das Polling bleibt als Rückfall** an derselben Adresse, mit derselben Rechteprüfung: wer kein
`EventSource` hat oder hinter einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg. Neu in
der Umgebung: `SSE_HERZSCHLAG_MS` (25000) und `SSE_MAX_STROEME` (200) (#033)
- Der Zustand eines Laufs trägt jetzt auch die Zahl der **Anhänge**. Ohne sie wäre ein angehängtes
Foto für die zweite Sitzung unsichtbar geblieben — die Anhänge kamen in #032 dazu, der Zustand
kannte sie nicht (#033)
- **`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
@@ -24,8 +43,24 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010 - Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
verlangt (#040) verlangt (#040)
### Behoben
- **nginx hätte ein 10-MB-Foto abgewiesen.** `docs/betrieb/reverse-proxy.md` empfahl
`client_max_body_size 8m`, während `ANHANG_MAX_MB` 10 MB erlaubt: ein Handyfoto am oberen Rand
wäre mit „413 Request Entity Too Large“ an der Wand gelandet, und die freundliche Meldung der
Anwendung wäre nie zum Zuge gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören
(#033)
### 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
+2 -1
View File
@@ -56,7 +56,8 @@ src/server/services/* Regeln und Schreiboperationen. Werfen eigene …Fehler-K
src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an. src/server/queries/* Lesemodelle. Wenden Rollen- und Mandantensicht selbst an.
src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service. src/server/actions/* Dünne "use server"-Einstiege. Prüfen genau ein Recht, rufen den Service.
src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar. src/lib/* Framework-freie Helfer. Kein Next, kein React, gut testbar.
src/app/api/* Route Handler. Nur Polling, QR-SVG, CSV, Auth, Healthcheck. Minimal. src/app/api/* Route Handler. Nur Zustandsstrom, QR-SVG, CSV, Anhaenge, Auth,
Healthcheck. Minimal.
src/components/* Darstellung. Keine Geschäftslogik. src/components/* Darstellung. Keine Geschäftslogik.
``` ```
+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:
+39 -2
View File
@@ -181,8 +181,18 @@ werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen.
### Zwei Techniker am selben Lauf ### Zwei Techniker am selben Lauf
Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas Hakt der Kollege einen Schritt ab, erscheint das bei dir **sofort** — ohne dass du etwas tun musst.
geändert hat. Bei verstecktem Tab pausiert die Abfrage. Dasselbe gilt für Kommentare, angehängte Fotos und einen geänderten Titel. Die Seite lädt sich nur
dann neu, wenn sich wirklich etwas geändert hat; an einem Feld, in das du gerade tippst, geht dabei
nichts verloren.
Ist die Lasche im Hintergrund, pausiert das. Holst du sie zurück, steht gleich der aktuelle Stand
da.
Bleibt die Seite doch einmal stehen, obwohl jemand anders arbeitet, aktualisiert sie sich spätestens
nach zehn Sekunden von selbst — das ist der eingebaute Rückfall. Dauert es dauerhaft so lange, liegt
es meist am Reverse Proxy; der Abschnitt „Die Seite aktualisiert sich nur langsam“ in
[Fehlerbehebung](08-fehlerbehebung.md) sagt, was zu tun ist.
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
@@ -195,6 +205,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
+52
View File
@@ -77,6 +77,58 @@ 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 Seite aktualisiert sich nur langsam
**Was du siehst:** Der Kollege hakt einen Schritt ab, und bei dir erscheint es erst nach rund zehn
Sekunden statt sofort. Alles funktioniert, es ist nur träge.
**Ursache:** Die Seite hält normalerweise eine offene Verbindung zum Server, über die Änderungen
sofort ankommen. Kommt die nicht zustande, schaltet sie von selbst auf Nachfragen im
Zehn-Sekunden-Takt um — die Arbeit geht also weiter, nur nicht in Echtzeit. Fast immer liegt das am
Reverse Proxy, der die Verbindung sammelt statt sie durchzulassen.
**Was zu tun ist** (für Administratoren): In `docs/betrieb/reverse-proxy.md` stehen die beiden
nötigen Zeilen — `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — und ein
Mindestwert für `proxy_read_timeout`. Fällt der unter 30 Sekunden, reißt die Verbindung zwischen
zwei Herzschlägen immer wieder ab.
**Zweitursache:** Sehr viele gleichzeitig offene Laschen. Ab einer Obergrenze (`SSE_MAX_STROEME`,
voreingestellt 200 je Instanz) bekommen weitere Laschen bewusst den langsamen Weg, damit der Server
erreichbar bleibt. Dann hilft, nicht mehr gebrauchte Laschen zu schließen.
**Was es nicht ist:** ein Datenverlust. Gespeichert wird beim Klick, nicht beim Aktualisieren. Was du
siehst, kann veraltet sein; was in der Datenbank steht, ist es nie.
## „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
+42 -10
View File
@@ -185,19 +185,51 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben. Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
## 5d. Warum die Aktualisierung keine Befehle mitliefert ## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert
Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei
teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden. Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu
Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle. laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und
Anhänge, ausdrücklich keine fertigen Befehle.
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die
Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als
Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal
Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas
hat. Bei verstecktem Tab pausiert die Abfrage. geändert hat. Bei verstecktem Tab wird der Strom geschlossen.
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück. Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück —
auch und gerade für den Strom, **bevor** das erste Ereignis fließt.
### Warum die Datenbank das Ereignis schickt, nicht der Dienst
Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn
zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn
Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung
je Prozess hört zu, und der Route Handler schiebt weiter.
**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen:
ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger
sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede**
Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst.
**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den
ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird.
Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein
**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die
vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands.
**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter
einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht
vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt.
Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser
`EventSource` weg.
Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie
erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und
versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen
Sonderfall und keinen eigenen Fehlercode.
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab. Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
## 5e. Der Aufkleber ist die Schnittstelle ## 5e. Der Aufkleber ist die Schnittstelle
+32 -7
View File
@@ -58,6 +58,14 @@ laufzettel.firma.de {
# gerade beim Neustart. Der Pfad braucht keine Anmeldung. # gerade beim Neustart. Der Pfad braucht keine Anmeldung.
health_uri /api/gesundheit health_uri /api/gesundheit
health_interval 30s health_interval 30s
# Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das
# heute an `Content-Type: text/event-stream` und reicht von sich aus
# sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn
# eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen
# sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie
# haengen geblieben.
flush_interval -1
} }
} }
``` ```
@@ -102,9 +110,16 @@ server {
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem; ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx # Der CSV-Import schickt die Datei als Text durch eine Server Action, und
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand. # seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur
client_max_body_size 8m; # 1 MB durch und waere damit die erste Wand.
#
# **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann
# reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request
# Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens
# 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt,
# hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars.
client_max_body_size 12m;
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
@@ -117,12 +132,22 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind # Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab. # /api/laeufe/<id>/zustand. Der Server schickt alle 25 s einen
# Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s
# sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich
# mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der
# Strom zwischen zwei Herzschlaegen immer wieder ab.
proxy_read_timeout 90s; proxy_read_timeout 90s;
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort # Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal. # Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite
# erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts
# an — sie sieht dann aus, als sei sie haengen geblieben.
#
# Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der
# Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt
# genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere.
proxy_buffering off; proxy_buffering off;
} }
} }
+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. |
+152 -2
View File
@@ -24,12 +24,20 @@ 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 |
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| | **Summe** | **50 h 05 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 | 14 h 45 min | in Arbeit |
--- ---
@@ -1089,3 +1097,145 @@ 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.
---
## 2026-10-05 · Sitzung 18 · 09:00–10:40
**Issue:** #033 (M20)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Die Live-Aktualisierung läuft über einen Ereignisstrom.** Vier Trigger schicken
`NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hört zu, der Route Handler
schiebt weiter. Gemessen: der Haken ist nach **485 ms** beim Zuschauer (Handy: 487 ms), vorher
bis zu zehn Sekunden.
- **Das Polling bleibt als Rückfall** — an derselben Adresse, unterschieden nur am `Accept`-Kopf.
- 1266 Vitest-Tests (vorher 1247), dazu `tests/e2e/live.spec.ts` mit zwei Browser-Kontexten.
- Der Herzschlag ist **echt über 70 Sekunden** gefahren: `readyState` blieb `OPEN`, ein einziges
Ereignis, kein Neuaufbau.
### Entscheidungen
- **Das Ereignis kommt aus der Datenbank, nicht aus dem Dienst.** Ein Ereignis, das der Service
verschickt, entsteht nur auf dem Weg durch den Service — ein Trigger sieht auch den Seed, eine
Migration und die Reparatur per `psql`. Und `NOTIFY` erreicht jede Instanz, ohne einen zweiten
Dienst wie Redis.
- **Ein Handler für beide Wege.** Damit ist „das Polling bleibt als Rückfall, nicht als zweite
Wahrheit“ keine Absichtserklärung, sondern Bauart: eine Rechteprüfung, ein Lesemodell, eine
Nutzlast.
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
mit JSON; `EventSource` meldet darauf einen Fehler und versucht es nicht wieder, und der Client
fällt von sich aus zurück. Kein eigener Fehlercode.
- **Keine Verbindung aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurück und verteilt sie neu;
ein `LISTEN` darauf gilt danach für eine fremde Anfrage oder für niemanden.
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert — sonst belegte jede
Hintergrundlasche genau die Verbindung, die die Obergrenze schützen soll.
### Probleme
- **Das Issue hätte einen Rückschritt gebaut.** Es nannte Trigger auf `Schrittergebnis` und `Lauf`.
`laufZustand` trägt aber auch die Zahl der Kommentare, und seit #032 gibt es Anhänge: der Strom
sieht **nur**, was gemeldet wird, während das Polling den ganzen Stand verglich. Mit den zwei
Triggern aus dem Issue wäre der Kommentar des Kollegen nie angekommen — und die Seite hätte
ausgesehen wie immer. Das ist derselbe Fehlertyp wie in #032: eine Zusage, die niemand prüft.
Jetzt melden vier Tabellen, `laufZustand` trägt `anzahlAnhaenge`, und beide Tests zählen namentlich
auf, was dazugehört.
- **Zwei Fehler in `reverse-proxy.md`, beide von vorgestern.** `client_max_body_size 8m` bei
`ANHANG_MAX_MB=10`: ein 10-MB-Foto wäre an nginx mit 413 gescheitert, und die eigene Meldung
(„Erlaubt sind höchstens 10,0 MB“) wäre nie erschienen. Dazu ein Kommentar, der noch das alte
Polling beschrieb. **Gefunden beim Schreiben der Doku, nicht von einem Test** — Dateien, die ein
Dritter liest, prüft hier niemand.
- **`## 5e` war in `docs/architektur.md` schon belegt.** Statt alles umzunummerieren und Verweise zu
riskieren, hängt der neue Text als `###` unter 5d.
- **Zwei eigene Testfehler, keine Produktfehler:** `schritte` zählte die „erledigt“-Knöpfe der
ganzen Seite (34) statt die des ersten Abschnitts (5), und ein `const` nach einem `?.`-Guard
verlor in der Closure seine Verengung. Beide Male war die Meldung eindeutig.
### Nächster Schritt
- **M21 (#034)** Benachrichtigungen per Mail und Webhook — baut direkt auf den Ereignissen auf, die
jetzt stehen. Danach **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.
+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.
+57 -15
View File
@@ -1,10 +1,10 @@
# #033 M20 – Live-Aktualisierung per SSE statt Polling # #033 M20 – Live-Aktualisierung per SSE statt Polling
**Status:** offen **Status:** erledigt
**Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53) **Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend **Labels:** phase-2, backend, frontend
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 1 h 40 min
## Ziel ## Ziel
@@ -17,36 +17,78 @@ Die Vorleistung steht: das Polling liegt in genau einem Route Handler
## Zu entscheiden ## Zu entscheiden
- [ ] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis` - [x] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung
und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne
einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre
dasselbe Polling, nur an anderer Stelle. dasselbe Polling, nur an anderer Stelle.
- [ ] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter - [x] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der
Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner — Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner —
deshalb ein Test je Weg. deshalb ein Test je Weg.
## Aufgaben ## Aufgaben
- [ ] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma - [x] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
kennt Trigger nicht und will sie löschen) kennt Trigger nicht und will sie löschen)
- [ ] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag - [x] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
(sonst schließen Proxys die Verbindung) und beendet sauber bei `abort` (sonst schließen Proxys die Verbindung) und beendet sauber bei `abort`
- [ ] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder - [x] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab) andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab)
- [ ] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei - [x] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch `error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
- [ ] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für - [x] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben
- [ ] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die - [x] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
Verbindungen nicht aufbraucht Verbindungen nicht aufbraucht
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer - [x] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
Sekunde, ohne neu zu laden Sekunde, ohne neu zu laden
- [ ] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt - [x] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
- [ ] Test: ein Lauf eines fremden Kunden liefert keinen Strom - [x] Test: ein Lauf eines fremden Kunden liefert keinen Strom
- [ ] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource` - [x] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
- [ ] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung - [x] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
## Beim Umsetzen dazugekommen
- **Es wäre ein Rückschritt geworden.** Das Issue nannte Trigger auf `Schrittergebnis` und `Lauf`.
`laufZustand` trägt aber auch die Zahl der **Kommentare** — das Polling verglich alle zehn
Sekunden den ganzen Stand und sah sie damit, ein Strom sieht nur, was gemeldet wird. Mit den zwei
Triggern aus dem Issue wäre der Kommentar des Kollegen **nie** angekommen, und niemandem wäre
etwas aufgefallen: die Seite sieht aus wie immer. Jetzt melden vier Tabellen, und
`tests/datenbankregeln.test.ts` zählt sie namentlich auf. Gegenprobe gefahren: ohne die Trigger auf
`Kommentar` und `Anhang` wird `tests/lauf-ereignisse.test.ts` rot.
- **`laufZustand` hat `anzahlAnhaenge` bekommen.** Anhänge gibt es seit #032, im Zustand standen sie
nicht. Ein Ereignis ohne Entsprechung im Zustand löst nichts aus, weil der Client vergleicht,
bevor er neu lädt — ein angehängtes Foto wäre also auch mit Trigger unsichtbar geblieben.
- **Ein Handler für beide Wege, unterschieden am `Accept`-Kopf.** So steht „Polling bleibt als
Rückfall, nicht als zweite Wahrheit“ nicht nur im Issue: es ist dieselbe Rechteprüfung, dasselbe
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand
`rechtErforderlich` vergessen kann.
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
mit JSON. `EventSource` verlangt `text/event-stream`, meldet einen Fehler und versucht es **nicht
wieder** — der Client fällt damit von sich aus auf Polling zurück. Kein eigener Fehlercode, kein
zweiter Pfad.
- **Eine LISTEN-Verbindung je Prozess, nicht je Strom** — und ausdrücklich **nicht** aus dem
Prisma-Pool: ein Pool gibt Verbindungen zurück und verteilt sie neu, ein `LISTEN` darauf gilt
danach für eine fremde Anfrage oder für niemanden.
- **Nach einem Verbindungsabbruch werden alle Zuhörer geweckt.** `NOTIFY` hat keinen
Zwischenspeicher; was während des Abbruchs passierte, ist verloren. Lieber ein Abruf zu viel als
ein stiller Stand, der falsch ist.
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert. Eine offene
Verbindung je Hintergrundlasche ist genau das, was die Obergrenze verhindern soll.
- **Zwei Fehler in `docs/betrieb/reverse-proxy.md` gefunden, beide aus #032:**
`client_max_body_size` stand auf **8m**, während `ANHANG_MAX_MB` 10 erlaubt — ein 10-MB-Foto wäre
an nginx mit „413“ gescheitert, und die freundliche Meldung der Anwendung wäre nie zum Zuge
gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören. Dazu beschrieb der
Kommentar am `proxy_read_timeout` noch das alte Polling.
- **Der Herzschlag ist echt über 70 Sekunden gefahren**, nicht nur im Test mit verkürztem Intervall:
`readyState` blieb `OPEN`, ein einziges Zustands-Ereignis, kein Fehler, kein Neuaufbau.
- **Gemessen statt behauptet:** der Haken war nach **485 ms** beim Zuschauer (Desktop) und nach
**487 ms** am Handy. Mit abgeschaltetem `EventSource` dauert derselbe Ablauf rund 13 Sekunden —
das ist zugleich der Nachweis, dass der Strom wirklich benutzt wird und nicht heimlich gepollt
wird.
- **Der Testname versprach `tests/e2e/live.spec.ts`**, also gibt es die Datei. Zwei Browser-Kontexte,
und für den Rückfall wird dem Zuschauer `EventSource` vor dem Laden weggenommen.
+3 -3
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) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
| #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** | **50 h 05 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}$');
@@ -0,0 +1,84 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: 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.
-- ---------------------------------------------------------------------------
-- Live-Aktualisierung per SSE statt Polling (#033).
--
-- Die Datenbank sagt selbst, wenn sich an einem Lauf etwas geaendert hat:
-- `NOTIFY lauf_geaendert, '<laufId>'`. Der Route Handler haelt eine
-- LISTEN-Verbindung und schiebt weiter.
--
-- **Warum in der Datenbank und nicht im Dienst.** Ein Ereignis, das der Service
-- verschickt, entsteht nur auf dem Weg durch den Service. Die Regeln dieses
-- Projekts stehen aber absichtlich auch in der Datenbank, weil jemand an der
-- Service-Schicht vorbei schreiben kann — der Seed, eine Migration, eine
-- Reparatur per psql. Ein Trigger sieht all das.
--
-- **Warum es mit mehreren Instanzen funktioniert.** NOTIFY geht an alle
-- Verbindungen, die LISTEN auf dem Kanal haben, also an jede Instanz der
-- Anwendung. Ein zweiter Dienst (Redis, ein Nachrichtenbus) waere dafuer nicht
-- noetig.
--
-- **Zugestellt wird erst beim COMMIT.** Das ist hier genau richtig: ein
-- Abonnent, der eine noch nicht festgeschriebene Aenderung meldet, wuerde die
-- Seite mit Daten neu bauen lassen, die es gleich wieder nicht gibt.
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert() RETURNS trigger AS $$
DECLARE
betroffener TEXT;
BEGIN
/*
* Bei DELETE gibt es kein NEW. Der Kanalname ist fest, die Nutzlast nicht –
* deshalb pg_notify() und nicht NOTIFY, das keine Variablen nimmt.
*/
IF TG_OP = 'DELETE' THEN
betroffener := OLD."laufId";
ELSE
betroffener := NEW."laufId";
END IF;
IF betroffener IS NOT NULL THEN
PERFORM pg_notify('lauf_geaendert', betroffener);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Derselbe Rumpf fuer die Laufzeile selbst: dort heisst der Schluessel "id".
CREATE OR REPLACE FUNCTION laufzettel_lauf_geaendert_selbst() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('lauf_geaendert', NEW."id");
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
/*
* **Welche Tabellen melden, haengt daran, was `laufZustand` liefert** — nicht
* daran, was sich "irgendwie" aendert. Das Lesemodell traegt Status,
* Schrittzustaende, die Zahl der Kommentare und (seit #033) die Zahl der
* Anhaenge. Wuerde eine davon nicht melden, waere der Strom gegenueber dem
* Polling, das alle zehn Sekunden vollstaendig vergleicht, ein **Rueckschritt**:
* ein neuer Kommentar kaeme nie an.
*/
CREATE TRIGGER "Lauf_geaendert_melden"
AFTER UPDATE ON "Lauf"
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert_selbst();
CREATE TRIGGER "Schrittergebnis_geaendert_melden"
AFTER INSERT OR UPDATE OR DELETE ON "Schrittergebnis"
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
CREATE TRIGGER "Kommentar_geaendert_melden"
AFTER INSERT OR UPDATE OR DELETE ON "Kommentar"
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
CREATE TRIGGER "Anhang_geaendert_melden"
AFTER INSERT OR DELETE ON "Anhang"
FOR EACH ROW EXECUTE FUNCTION laufzettel_lauf_geaendert();
+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;
}
}
+139 -8
View File
@@ -1,21 +1,41 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZustand } from "@/server/queries/laeufe"; import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
/** /**
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013). * Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
* *
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx` * **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy * deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). * sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
* erste Ereignis fliesst.
*
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
* *
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}` * Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte * entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt. * Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser * Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
* am selben Lauf voneinander wissen muessen. * was zwei Browser am selben Lauf voneinander wissen muessen.
*/ */
/*
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
* fassen einen Schwung zusammen.
*/
const SAMMELFENSTER_MS = 150;
export async function GET( export async function GET(
_anfrage: Request, anfrage: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
): Promise<Response> { ): Promise<Response> {
let benutzerId: string; let benutzerId: string;
@@ -34,16 +54,127 @@ export async function GET(
} }
const { id } = await params; const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle }); const benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer);
if (!zustand) { if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden. // Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 }); return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
} }
/*
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
*/
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
return Response.json(zustand, { return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie // Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt. // aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" }, headers: { "Cache-Control": "no-store" },
}); });
} }
const kodierer = new TextEncoder();
let abmelden: (() => void) | undefined;
let herzschlag: ReturnType<typeof setInterval> | undefined;
let sammler: ReturnType<typeof setTimeout> | undefined;
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
function schicken(text: string): void {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(text));
} catch {
// Gegenseite ist weg, bevor `abort` ankam.
offen = false;
}
}
function aufraeumen(): void {
if (!offen) return;
offen = false;
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
try {
steuerung.close();
} catch {
// Schon geschlossen.
}
}
async function standSchicken(): Promise<void> {
if (!offen) return;
try {
const jetzt = await laufZustand(id, benutzer);
if (!jetzt) {
/*
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
* entzogen wurde. Der Strom endet; der Client holt sich an der
* gewoehnlichen Adresse die 404 und stellt ein.
*/
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
aufraeumen();
return;
}
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
} catch (fehler) {
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
}
}
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
// ohne zusaetzlich zu pollen.
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
try {
abmelden = await aufLaufHoeren(id, () => {
if (sammler) return;
sammler = setTimeout(() => {
sammler = undefined;
void standSchicken();
}, SAMMELFENSTER_MS);
});
} catch (fehler) {
/*
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
*/
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
aufraeumen();
return;
}
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen);
},
cancel() {
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
/*
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
*/
"X-Accel-Buffering": "no",
},
});
}
+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>
+153 -33
View File
@@ -1,34 +1,45 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
/** /**
* Holt alle zehn Sekunden den Zustand des Laufs (#013). * Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033).
* *
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus * Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
* dem Auftrag: zwei Techniker teilen sich ein Onboarding. * dem Auftrag: zwei Techniker teilen sich ein Onboarding.
* *
* Vier Entscheidungen stecken darin: * **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank
* meldet die Aenderung, der Server schiebt sie weiter, und der Haken des
* Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu
* zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit
* derselben Nutzlast.
*
* Fuenf Entscheidungen stecken darin:
* *
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im * 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer * Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert:
* niemanden. * eine offene Verbindung je Hintergrundlasche ist genau das, was die
* Obergrenze im Handler verhindern soll.
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine * 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht * Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur, * wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn * wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt. * Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst * 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der * neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen * Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine * Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn * `laufZustand`) — und diese Zusage waere nichts wert, wenn die
* das Polling stattdessen die ganze Seite neu bauen liesse. * Aktualisierung stattdessen die ganze Seite neu bauen liesse.
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein * 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
* verschwundener Lauf sind kein Grund weiterzufragen. * verschwundener Lauf sind kein Grund weiterzufragen.
* 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst
* schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe
* Zeile ins Protokoll der Konsole.
*/ */
/** /**
* Was ein geaenderter Zustand ausloesen soll. * Was ein geaenderter Zustand ausloesen soll.
* *
@@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "
return passwortSichtbar ? "fragen" : "neu-laden"; return passwortSichtbar ? "fragen" : "neu-laden";
} }
/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */
export const STROM_GESCHLOSSEN = 2;
/**
* Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung?
*
* `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht
* `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben.
* Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort
* nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter
* Obergrenze, und genau dann soll gepollt werden.
*/
export function sollZurueckfallen(readyState: number): boolean {
return readyState === STROM_GESCHLOSSEN;
}
export function LaufAktualisierung({ export function LaufAktualisierung({
laufId, laufId,
passwortSichtbar = false, passwortSichtbar = false,
@@ -57,29 +84,46 @@ export function LaufAktualisierung({
const letzterStand = useRef<string | null>(null); const letzterStand = useRef<string | null>(null);
const [neues, setzeNeues] = useState(false); const [neues, setzeNeues] = useState(false);
useEffect(() => { /**
let abgebrochen = false; * Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren.
let zeitgeber: ReturnType<typeof setTimeout> | undefined; *
* Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal
async function holen(): Promise<void> { * geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der
if (abgebrochen || document.visibilityState === "hidden") { * Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten.
plane(); */
return; const standVerarbeiten = useCallback(
} (stand: string): void => {
try {
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
abgebrochen = true;
return;
}
if (antwort.ok) {
const stand = JSON.stringify(await antwort.json());
if (letzterStand.current !== null && letzterStand.current !== stand) { if (letzterStand.current !== null && letzterStand.current !== stand) {
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true); if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
else router.refresh(); else router.refresh();
} }
letzterStand.current = stand; letzterStand.current = stand;
},
[passwortSichtbar, router],
);
useEffect(() => {
const adresse = `/api/laeufe/${laufId}/zustand`;
let eingestellt = false;
let quelle: EventSource | undefined;
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
let polltJetzt = false;
let rueckfallGemeldet = false;
/* ---------------- Polling: der Rueckfall ---------------- */
async function holen(): Promise<void> {
if (eingestellt || document.visibilityState === "hidden") {
plane();
return;
} }
try {
const antwort = await fetch(adresse, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
eingestellt = true;
return;
}
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
} catch { } catch {
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder. // Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
} }
@@ -87,23 +131,99 @@ export function LaufAktualisierung({
} }
function plane(): void { function plane(): void {
if (abgebrochen) return; if (eingestellt || !polltJetzt) return;
zeitgeber = setTimeout(() => void holen(), 10_000); zeitgeber = setTimeout(() => void holen(), 10_000);
} }
function aufPollingUmstellen(grund: string): void {
if (eingestellt || polltJetzt) return;
polltJetzt = true;
stromSchliessen();
if (!rueckfallGemeldet) {
rueckfallGemeldet = true;
// Einmal, nicht bei jedem Versuch – siehe Kopfkommentar.
console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`);
}
void holen(); void holen();
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten. }
const beiSichtbarkeit = () => {
if (document.visibilityState === "visible") void holen(); /* ---------------- Strom: der Normalfall ---------------- */
function stromSchliessen(): void {
quelle?.close();
quelle = undefined;
}
function stromOeffnen(): void {
if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return;
const neu = new EventSource(adresse);
quelle = neu;
neu.addEventListener("zustand", (ereignis) => {
standVerarbeiten((ereignis as MessageEvent<string>).data);
});
// Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen.
neu.addEventListener("ende", () => {
eingestellt = true;
stromSchliessen();
});
neu.addEventListener("error", () => {
if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen");
// Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun.
});
}
/* ---------------- Sichtbarkeit ---------------- */
const beiSichtbarkeit = (): void => {
if (eingestellt) return;
if (document.visibilityState === "hidden") {
// Verbindung wirklich freigeben, nicht bloss ignorieren.
stromSchliessen();
if (zeitgeber) clearTimeout(zeitgeber);
return;
}
/*
* Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren.
* Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf
* stuende der alte Stand da, bis das naechste Mal jemand etwas aendert.
*/
if (polltJetzt) void holen();
else {
stromOeffnen();
void einmalHolen();
}
}; };
/** Ein einzelner Abruf, ohne das Polling einzuschalten. */
async function einmalHolen(): Promise<void> {
try {
const antwort = await fetch(adresse, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
eingestellt = true;
stromSchliessen();
return;
}
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
} catch {
// Der Strom liefert den Stand ohnehin gleich mit.
}
}
if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource");
else stromOeffnen();
document.addEventListener("visibilitychange", beiSichtbarkeit); document.addEventListener("visibilitychange", beiSichtbarkeit);
return () => { return () => {
abgebrochen = true; eingestellt = true;
stromSchliessen();
if (zeitgeber) clearTimeout(zeitgeber); if (zeitgeber) clearTimeout(zeitgeber);
document.removeEventListener("visibilitychange", beiSichtbarkeit); document.removeEventListener("visibilitychange", beiSichtbarkeit);
}; };
}, [laufId, router, passwortSichtbar]); }, [laufId, standVerarbeiten]);
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich. // Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
if (!neues) return null; if (!neues) return null;
+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);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Server-Sent Events: Format und Grenzen (#033).
*
* Framework-frei, weil beides geprueft werden muss, ohne eine Verbindung
* aufzubauen. Das Format ist winzig und hat genau eine Falle, die einen Strom
* still zerstoert — siehe `sseEreignis`.
*/
/** Der Kanal, auf dem die Datenbank Aenderungen meldet. Muss zur Migration passen. */
export const KANAL = "lauf_geaendert";
/**
* Ein Ereignis im SSE-Format.
*
* **Die Falle:** ein Zeilenumbruch in den Daten beendet das Feld. Steht in der
* Nutzlast ein `\n`, zerfaellt die Nachricht, und der Client bekommt Schrott
* oder gar nichts. Deshalb wird **jede** Zeile mit eigenem `data:` gesendet —
* so schreibt es die Spezifikation vor, und der Client fuegt sie mit `\n`
* wieder zusammen.
*
* Unsere Nutzlast ist JSON aus `JSON.stringify` ohne Einrueckung und hat heute
* nie einen Umbruch. „Hat heute nie“ ist aber keine Zusage, sondern ein Zufall:
* ein Lauftitel mit Umbruch, ein Notiztext, und der Strom reisst.
*/
export function sseEreignis(name: string, daten: string): string {
const zeilen = daten.split("\n").map((zeile) => `data: ${zeile}`);
return `event: ${name}\n${zeilen.join("\n")}\n\n`;
}
/**
* Der Herzschlag: ein Kommentar, den der Client verwirft.
*
* Ohne ihn schliesst ein Reverse Proxy die stille Verbindung nach seiner
* Leerlaufzeit — nginx nach 60 s, und der Browser sieht nur einen Abbruch.
* Ein Kommentar (Zeile mit `:`) kostet zwei Bytes und haelt sie offen.
*/
export const HERZSCHLAG = ": hz\n\n";
const STANDARD_HERZSCHLAG_MS = 25_000;
const STANDARD_MAX_STROEME = 200;
/**
* Abstand der Herzschlaege.
*
* 25 s, also unter der Leerlaufzeit von 60 s, die nginx und Caddy voreingestellt
* haben. Umstellbar, weil der Test nicht eine Minute warten soll, um zu sehen,
* dass ueberhaupt einer kommt.
*/
export function herzschlagMs(): number {
const roh = Number.parseInt(process.env["SSE_HERZSCHLAG_MS"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_HERZSCHLAG_MS;
}
/**
* Hoechstzahl gleichzeitig offener Stroeme je Instanz.
*
* Ein Strom belegt eine HTTP-Verbindung, solange die Lasche offen ist. Ohne
* Grenze belegt ein Haus mit vierzig offenen Laschen vierzig Verbindungen, und
* irgendwann nimmt der Server keine gewoehnliche Anfrage mehr an — die Seite
* waere dann nicht langsam, sondern tot. Ist die Grenze erreicht, antwortet der
* Handler gewoehnlich mit JSON, und der Client fuellt auf Polling zurueck.
*/
export function maxStroeme(): number {
const roh = Number.parseInt(process.env["SSE_MAX_STROEME"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_STROEME;
}
/**
* Will der Aufrufer einen Strom?
*
* `EventSource` schickt `Accept: text/event-stream` von sich aus — das ist der
* einzige Unterschied zwischen dem Strom und dem Polling, und deshalb liegen
* beide an **derselben** Adresse: eine Rechtepruefung, ein Lesemodell, eine
* Wahrheit. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand das
* Recht vergessen kann.
*/
export function willStrom(accept: string | null): boolean {
return (accept ?? "").split(",").some((teil) => teil.trim().startsWith("text/event-stream"));
}
+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.");
}
}
+195
View File
@@ -0,0 +1,195 @@
import { Client } from "pg";
import { KANAL } from "@/lib/sse";
/**
* Aenderungen an einem Lauf, wie die Datenbank sie meldet (#033).
*
* Die Trigger aus `20261005090000_lauf_ereignisse` schicken
* `NOTIFY lauf_geaendert, '<laufId>'`. Hier haengt **eine** Verbindung je
* Prozess mit `LISTEN` daran und verteilt an die offenen Stroeme.
*
* **Eine Verbindung, nicht eine je Strom.** `LISTEN` belegt eine Verbindung
* dauerhaft; eine je Lasche waere bei vierzig Laschen vierzig belegte
* Datenbankverbindungen, und PostgreSQL erlaubt voreingestellt hundert
* insgesamt. Die Nutzlast sagt, welcher Lauf gemeint ist — das Verteilen
* passiert hier im Prozess.
*
* **Nicht aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurueck und
* verteilt sie neu; ein `LISTEN` darauf gilt nach der Rueckgabe fuer eine
* fremde Anfrage oder fuer niemanden. Deshalb ein eigener `pg.Client`.
*/
type Rueckruf = () => void;
type Register = {
readonly abonnenten: Map<string, Set<Rueckruf>>;
verbindung: Client | undefined;
aufbau: Promise<void> | undefined;
geschlossen: boolean;
};
/*
* An `globalThis`, aus demselben Grund wie der Prisma-Client in `src/lib/db.ts`:
* Hot Reload laedt das Modul neu und baute sonst bei jedem Speichern eine
* weitere LISTEN-Verbindung auf, ohne die alte zu schliessen.
*/
const global = globalThis as typeof globalThis & { laufzettelEreignisse?: Register };
function register(): Register {
global.laufzettelEreignisse ??= {
abonnenten: new Map(),
verbindung: undefined,
aufbau: undefined,
geschlossen: false,
};
return global.laufzettelEreignisse;
}
/** Alle Abonnenten eines Laufs wecken. */
function wecken(laufId: string): void {
for (const rueckruf of register().abonnenten.get(laufId) ?? []) {
try {
rueckruf();
} catch (fehler) {
// Ein kaputter Abonnent darf die anderen nicht mitnehmen.
console.error("Abonnent eines Lauf-Ereignisses hat geworfen:", fehler);
}
}
}
/** Alle Abonnenten wecken, egal welcher Lauf. Nach einem Verbindungsabbruch. */
function alleWecken(): void {
for (const laufId of [...register().abonnenten.keys()]) wecken(laufId);
}
async function verbindungAufbauen(): Promise<void> {
const eigen = register();
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
const client = new Client({ connectionString: url });
/*
* **Erst der Fehlerbehandler, dann verbinden.** Ein `error` auf einem Client
* ohne Behandler beendet in Node den ganzen Prozess — und eine Datenbank, die
* neu startet, ist der Normalfall, nicht die Ausnahme.
*/
client.on("error", (fehler) => {
console.error("LISTEN-Verbindung verloren:", fehler.message);
eigen.verbindung = undefined;
void wiederverbinden();
});
client.on("end", () => {
if (eigen.verbindung === client) {
eigen.verbindung = undefined;
void wiederverbinden();
}
});
client.on("notification", (nachricht) => {
if (nachricht.channel === KANAL && nachricht.payload) wecken(nachricht.payload);
});
await client.connect();
await client.query(`LISTEN ${KANAL}`);
eigen.verbindung = client;
/*
* **Nach einem Aufbau alle wecken.** Zwischen Abbruch und Wiederverbindung
* sind Meldungen verloren gegangen — NOTIFY hat keinen Zwischenspeicher. Wer
* jetzt nicht nachfragt, sieht den Haken des Kollegen nie. Lieber ein Abruf
* zu viel als ein stiller Stand, der falsch ist.
*/
alleWecken();
}
/**
* Nach einem Abbruch neu verbinden, mit wachsendem Abstand.
*
* Ohne Abstand liefe bei einer laenger weg gebliebenen Datenbank eine
* Endlosschleife, die nichts tut als Verbindungen zu versuchen.
*/
async function wiederverbinden(versuch = 1): Promise<void> {
const eigen = register();
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
const wartezeit = Math.min(30_000, 500 * 2 ** (versuch - 1));
await new Promise((fertig) => setTimeout(fertig, wartezeit));
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
try {
await verbindungAufbauen();
} catch {
await wiederverbinden(versuch + 1);
}
}
/** Sicherstellen, dass die eine Verbindung steht. Mehrfachaufrufe teilen sich den Aufbau. */
async function verbindungSicherstellen(): Promise<void> {
const eigen = register();
eigen.geschlossen = false;
if (eigen.verbindung) return;
// Laufender Aufbau: anhaengen statt einen zweiten starten.
eigen.aufbau ??= verbindungAufbauen().finally(() => {
eigen.aufbau = undefined;
});
await eigen.aufbau;
}
/**
* Auf Aenderungen eines Laufs hoeren.
*
* Gibt die Funktion zurueck, mit der das Abonnement endet. Sie **muss** gerufen
* werden, wenn der Strom schliesst — sonst waechst das Register mit jeder
* geschlossenen Lasche weiter, und der Zaehler fuer die Obergrenze erzaehlt
* Unsinn.
*/
export async function aufLaufHoeren(laufId: string, rueckruf: Rueckruf): Promise<() => void> {
const eigen = register();
const menge = eigen.abonnenten.get(laufId) ?? new Set<Rueckruf>();
menge.add(rueckruf);
eigen.abonnenten.set(laufId, menge);
try {
await verbindungSicherstellen();
} catch (fehler) {
// Aufraeumen, sonst zaehlt ein nie aufgebauter Strom weiter mit.
menge.delete(rueckruf);
if (menge.size === 0) eigen.abonnenten.delete(laufId);
throw fehler;
}
let beendet = false;
return () => {
if (beendet) return;
beendet = true;
menge.delete(rueckruf);
if (menge.size === 0) eigen.abonnenten.delete(laufId);
};
}
/**
* Zahl der offenen Stroeme in diesem Prozess.
*
* Grundlage der Obergrenze. Bewusst die Summe der Abonnenten und nicht die Zahl
* der Laeufe: zwei Techniker an einem Lauf sind zwei Verbindungen.
*/
export function stroemeOffen(): number {
let summe = 0;
for (const menge of register().abonnenten.values()) summe += menge.size;
return summe;
}
/**
* Die Verbindung schliessen und alle Abonnements verwerfen.
*
* Fuer den geordneten Abbau und fuer Tests, die nicht auf einer offenen
* Verbindung sitzen bleiben sollen.
*/
export async function ereignisseSchliessen(): Promise<void> {
const eigen = register();
eigen.geschlossen = true;
eigen.abonnenten.clear();
const client = eigen.verbindung;
eigen.verbindung = undefined;
if (client) await client.end().catch(() => undefined);
}
+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,
})),
}; };
}); });
+12 -1
View File
@@ -456,6 +456,16 @@ export type LaufZustand = {
* (Reviewbefund zu #013). * (Reviewbefund zu #013).
*/ */
readonly anzahlKommentare: number; readonly anzahlKommentare: number;
/**
* Zahl der Anhaenge (#033).
*
* Aus demselben Grund wie die Kommentare: ein Foto, das der Kollege gerade an
* einen Schritt gehaengt hat, blieb sonst unsichtbar. Seit #032 gibt es
* Anhaenge, und seit #033 meldet die Datenbank ihre Aenderung — ein Ereignis
* ohne Entsprechung im Zustand loeste aber nichts aus, weil der Client
* vergleicht, bevor er neu laedt.
*/
readonly anzahlAnhaenge: number;
readonly geaendertAm: Date; readonly geaendertAm: Date;
readonly schritte: readonly { readonly schritte: readonly {
readonly schrittId: string; readonly schrittId: string;
@@ -487,7 +497,7 @@ export async function laufZustand(
select: { select: {
status: true, status: true,
geaendertAm: true, geaendertAm: true,
_count: { select: { kommentare: true } }, _count: { select: { kommentare: true, anhaenge: true } },
ergebnisse: { ergebnisse: {
select: { select: {
vorlagenSchrittId: true, vorlagenSchrittId: true,
@@ -504,6 +514,7 @@ export async function laufZustand(
return { return {
status: lauf.status, status: lauf.status,
anzahlKommentare: lauf._count.kommentare, anzahlKommentare: lauf._count.kommentare,
anzahlAnhaenge: lauf._count.anhaenge,
geaendertAm: lauf.geaendertAm, geaendertAm: lauf.geaendertAm,
anzahlPflichtOffen: lauf.ergebnisse.filter( anzahlPflichtOffen: lauf.ergebnisse.filter(
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand), (ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
+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:?");
+33 -3
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",
@@ -50,7 +56,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
}); });
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => { it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
expect(await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal`)).toEqual([ expect(
await namen(
`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname NOT LIKE '%_melden'`,
),
).toEqual([
"BausteinSchritt_unveraenderlich", "BausteinSchritt_unveraenderlich",
"BausteinVersion_status_einbahn", "BausteinVersion_status_einbahn",
"VorlagenAbschnitt_baustein_veroeffentlicht", "VorlagenAbschnitt_baustein_veroeffentlicht",
@@ -61,6 +71,24 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
]); ]);
}); });
it("hat die Trigger, die eine Änderung am Lauf melden", async () => {
/*
* Ohne sie bleibt die Live-Aktualisierung still, und zwar **lautlos**: die
* Seite sieht aus wie immer, nur sieht niemand mehr die Arbeit des anderen.
* Welche Tabellen dabei sind, haengt an dem, was `laufZustand` liefert —
* fehlte `Kommentar` oder `Anhang`, waere der Strom gegenueber dem alten
* Polling ein Rueckschritt (#033).
*/
expect(
await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE '%_melden'`),
).toEqual([
"Anhang_geaendert_melden",
"Kommentar_geaendert_melden",
"Lauf_geaendert_melden",
"Schrittergebnis_geaendert_melden",
]);
});
it("hat die partiellen Eindeutigkeitsindizes", async () => { it("hat die partiellen Eindeutigkeitsindizes", async () => {
expect( expect(
await namen( await namen(
@@ -80,6 +108,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);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Zwei Sitzungen an einem Lauf (#033).
*
* Das ist das Abnahmekriterium, das sich nur hier pruefen laesst: **hakt die
* eine ab, sieht die andere es ohne neu zu laden.** Mit dem Polling aus #013 war
* das richtig, aber bis zu zehn Sekunden langsam; seit dem Strom kommt es
* sofort.
*
* Und der zweite Ablauf prueft den Rueckfall. Ein Rueckfall, der nie gefahren
* wird, ist keiner — dieses Projekt hat genau dafuer schon bezahlt (#010: eine
* Oberflaeche, die serverseitig richtig aussah und nie hydrierte).
*/
const STEMPEL = Date.now();
/** Legt einen Lauf an, beginnt ihn und gibt seine Adresse zurueck. */
async function laufVorbereiten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
await kundeWaehlen(seite, geraet.kundeId);
await seite.selectOption('select[name="geraetId"]', geraet.id);
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
await seite.getByLabel("Titel", { exact: true }).fill(titel);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
await seite.getByRole("button", { name: "Lauf beginnen" }).click();
await expect(seite.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
return new URL(seite.url()).pathname;
}
/**
* Den ersten Abschnitt aufklappen und Kopf **und** Bereich zurueckgeben.
*
* Gezaehlt wird spaeter im Bereich, nicht auf der Seite: die Vorlage hat 34
* Schritte in sieben Abschnitten, und `0 von 34` steht in keinem Kopf.
*/
async function ersterAbschnitt(seite: Page) {
const bereich = seite
.locator("section")
.filter({ has: seite.locator("button[aria-expanded]") })
.first();
const kopf = bereich.locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
return { kopf, bereich };
}
test.describe("Live-Aktualisierung", () => {
test("die zweite Sitzung sieht den Haken der ersten, ohne neu zu laden", async ({ page, browser }) => {
test.slow();
await anmelden(page);
const pfad = await laufVorbereiten(page, `ZZ E2E Live ${STEMPEL}`);
// Zweite Sitzung: eigener Kontext, eigenes Cookie, eigener Strom.
const zweiter = await browser.newContext();
const zuschauer = await zweiter.newPage();
try {
await anmelden(zuschauer);
await zuschauer.goto(pfad);
const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page);
const { kopf: kopfB } = await ersterAbschnitt(zuschauer);
const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count();
expect(schritte).toBeGreaterThan(0);
await expect(kopfB).toContainText(`0 von ${schritte}`);
/*
* Jetzt der eigentliche Nachweis. Gewartet wird **nur** auf den Zaehler im
* Kopf des Zuschauers — kein `reload()`, kein Klick. Kommt er dort an, hat
* der Strom funktioniert.
*/
const start = Date.now();
await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click();
await expect(kopfA).toContainText(`1 von ${schritte}`);
await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 8_000 });
const dauer = Date.now() - start;
/*
* **Warum 5000 und nicht 1000.** Das Kriterium nennt „unter einer
* Sekunde“, und gemessen sind es hier gewoehnlich zwei- bis
* dreihundert Millisekunden. Eine Schranke von 1000 ms waere aber auf
* einer belasteten Maschine ein Test, der gelegentlich ohne Fehler im Code
* rot wird — und ein flatternder Test wird irgendwann ignoriert. 5000 ms
* beweisen das Entscheidende: **es wurde nicht gepollt**, denn das Polling
* fragt alle zehn Sekunden.
*/
expect(dauer).toBeLessThan(5_000);
console.log(`Der Haken war nach ${dauer} ms beim Zuschauer.`);
} finally {
await zweiter.close();
}
});
test("ohne EventSource kommt die Änderung über das Polling an", async ({ page, browser }) => {
test.slow();
await anmelden(page);
const pfad = await laufVorbereiten(page, `ZZ E2E Rückfall ${STEMPEL}`);
const zweiter = await browser.newContext();
const zuschauer = await zweiter.newPage();
try {
/*
* `EventSource` wegnehmen, **bevor** die Seite laedt. Genau diesen Zustand
* hat ein Browser hinter einem Proxy, der Stroeme puffert: der Strom kommt
* nie an, und ohne Rueckfall sieht die Seite eine Stunde lang unveraendert
* aus.
*/
await zuschauer.addInitScript(() => {
// @ts-expect-error – genau das ist der Punkt des Ablaufs.
delete window.EventSource;
});
await anmelden(zuschauer);
await zuschauer.goto(pfad);
expect(await zuschauer.evaluate(() => typeof EventSource)).toBe("undefined");
const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page);
const { kopf: kopfB } = await ersterAbschnitt(zuschauer);
const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count();
await expect(kopfB).toContainText(`0 von ${schritte}`);
await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click();
await expect(kopfA).toContainText(`1 von ${schritte}`);
// Das Polling fragt alle zehn Sekunden – entsprechend mehr Luft.
await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 25_000 });
} finally {
await zweiter.close();
}
});
});
+509
View File
@@ -0,0 +1,509 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums";
import { HERZSCHLAG, KANAL, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import {
STROM_GESCHLOSSEN,
reaktionAufAenderung,
sollZurueckfallen,
} from "@/components/laeufe/lauf-aktualisierung";
import { RechteFehler } from "@/server/auth/sitzungspflicht";
import { aufLaufHoeren, ereignisseSchliessen, stroemeOffen } from "@/server/lauf-ereignisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Live-Aktualisierung per SSE statt Polling (#033).
*
* Vier Zusagen tragen das Ganze: die Datenbank meldet **jede** Aenderung, die im
* Zustand steht; der Strom prueft Recht und Mandant, bevor das erste Ereignis
* fliesst; der Herzschlag haelt die Verbindung offen; und das Polling bleibt ein
* echter Rueckfall.
*
* Die Pruefung „zwei Sitzungen sehen einander in unter einer Sekunde“ steht in
* `tests/e2e/live.spec.ts` — hier wird gezeigt, dass die Teile einzeln halten.
*/
/* Der Handler wird echt gefahren; nur die Sitzung wird gestellt. */
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return { ...echt, rechtErforderlich: vi.fn() };
});
const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht");
const { GET } = await import("@/app/api/laeufe/[id]/zustand/route");
describe("Format und Grenzen des Stroms", () => {
it("schreibt ein Ereignis im SSE-Format", () => {
expect(sseEreignis("zustand", '{"a":1}')).toBe('event: zustand\ndata: {"a":1}\n\n');
});
it("gibt jeder Zeile ihr eigenes data-Feld", () => {
/*
* Die Falle des Formats: ein Zeilenumbruch beendet das Feld. Ohne diese
* Aufteilung zerfiele die Nachricht, und der Client bekäme Schrott.
*/
expect(sseEreignis("zustand", "erste\nzweite")).toBe("event: zustand\ndata: erste\ndata: zweite\n\n");
});
it("erkennt den Wunsch nach einem Strom am Accept-Kopf", () => {
expect(willStrom("text/event-stream")).toBe(true);
// So schickt es der Browser wirklich.
expect(willStrom("text/event-stream, */*;q=0.1")).toBe(true);
expect(willStrom("text/event-stream;charset=utf-8")).toBe(true);
expect(willStrom("application/json")).toBe(false);
expect(willStrom("*/*")).toBe(false);
expect(willStrom(null)).toBe(false);
});
it("nimmt Herzschlag und Obergrenze aus der Umgebung, mit Vorgabe", () => {
expect(herzschlagMs()).toBe(25_000);
expect(maxStroeme()).toBe(200);
process.env["SSE_HERZSCHLAG_MS"] = "50";
process.env["SSE_MAX_STROEME"] = "3";
expect(herzschlagMs()).toBe(50);
expect(maxStroeme()).toBe(3);
// Unsinn fällt auf die Vorgabe zurück, statt den Strom lahmzulegen.
process.env["SSE_HERZSCHLAG_MS"] = "0";
process.env["SSE_MAX_STROEME"] = "spaeter";
expect(herzschlagMs()).toBe(25_000);
expect(maxStroeme()).toBe(200);
delete process.env["SSE_HERZSCHLAG_MS"];
delete process.env["SSE_MAX_STROEME"];
});
it("fällt nur bei endgültig geschlossenem Strom auf Polling zurück", () => {
// `EventSource` verbindet sich bei CONNECTING selbst wieder – aufzugeben
// wäre verfrüht.
expect(sollZurueckfallen(0)).toBe(false);
expect(sollZurueckfallen(1)).toBe(false);
expect(sollZurueckfallen(STROM_GESCHLOSSEN)).toBe(true);
});
it("fragt statt neu zu laden, wenn ein Passwort auf der Seite steht", () => {
expect(reaktionAufAenderung(true)).toBe("fragen");
expect(reaktionAufAenderung(false)).toBe("neu-laden");
});
});
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: "Rechner auspacken", position: 0, pflicht: true },
});
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,
};
}
/** Wartet, bis die Bedingung gilt – oder bricht mit einer lesbaren Meldung ab. */
async function warteAuf(bedingung: () => boolean, was: string, grenzeMs = 5_000): Promise<void> {
const ende = Date.now() + grenzeMs;
while (Date.now() < ende) {
if (bedingung()) return;
await new Promise((fertig) => setTimeout(fertig, 10));
}
throw new Error(`${was} ist innerhalb von ${grenzeMs} ms nicht eingetreten.`);
}
describe.runIf(datenbankVerfuegbar)("Lauf-Ereignisse aus der Datenbank", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterEach(async () => {
await ereignisseSchliessen();
});
afterAll(async () => {
await ereignisseSchliessen();
});
it("meldet den Kanalnamen, den die Migration benutzt", async () => {
// Liefe der Name auseinander, hörte niemand zu – und nichts wäre rot.
const zeilen = await testPrisma.$queryRaw<{ quelle: string }[]>`
SELECT prosrc AS quelle FROM pg_proc WHERE proname = 'laufzettel_lauf_geaendert'
`;
expect(zeilen).toHaveLength(1);
expect(zeilen[0]?.quelle).toContain(`pg_notify('${KANAL}'`);
});
it("weckt bei einem abgehakten Schritt", async () => {
const { laufId, ergebnisId, admin } = await aufbau();
let geweckt = 0;
const abmelden = await aufLaufHoeren(laufId, () => {
geweckt += 1;
});
await testPrisma.schrittergebnis.update({
where: { id: ergebnisId },
data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id },
});
await warteAuf(() => geweckt > 0, "Die Meldung zum Schrittergebnis");
abmelden();
});
it("weckt auch bei Kommentar, Anhang und Kopfdaten", async () => {
/*
* **Der eigentliche Rückschritt-Test.** `laufZustand` trägt die Zahl der
* Kommentare und Anhänge; das Polling verglich alle zehn Sekunden den
* **ganzen** Stand und sah sie deshalb. Ein Trigger nur auf
* `Schrittergebnis` wäre gegenüber dem Polling schlechter — ein Kommentar
* des Kollegen käme nie an.
*/
const { laufId, ergebnisId, kunde, admin } = await aufbau();
let geweckt = 0;
const abmelden = await aufLaufHoeren(laufId, () => {
geweckt += 1;
});
const kommentar = await testPrisma.kommentar.create({
data: { laufId, rumpf: "Netzteil fehlte", benutzerId: admin.id },
});
await warteAuf(() => geweckt >= 1, "Die Meldung zum Kommentar");
const vorher = geweckt;
const anhang = await testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnisId,
laufId,
kundeId: kunde.id,
dateiname: "typenschild.png",
medientyp: "image/png",
groesse: 10,
pruefsumme: "b".repeat(64),
},
});
await warteAuf(() => geweckt > vorher, "Die Meldung zum Anhang");
const vorTitel = geweckt;
await testPrisma.lauf.update({ where: { id: laufId }, data: { titel: "Neuer Titel" } });
await warteAuf(() => geweckt > vorTitel, "Die Meldung zu den Kopfdaten");
// Und beim Entfernen genauso – sonst bliebe ein gelöschter Anhang stehen.
const vorLoeschen = geweckt;
await testPrisma.anhang.delete({ where: { id: anhang.id } });
await warteAuf(() => geweckt > vorLoeschen, "Die Meldung zum entfernten Anhang");
const vorKommentarWeg = geweckt;
await testPrisma.kommentar.delete({ where: { id: kommentar.id } });
await warteAuf(() => geweckt > vorKommentarWeg, "Die Meldung zum entfernten Kommentar");
abmelden();
});
it("weckt nur die Zuhörer des betroffenen Laufs", async () => {
const { laufId, ergebnisId, kunde, admin } = await aufbau();
const version = await testPrisma.vorlagenVersion.findFirstOrThrow();
const anderer = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Ein anderer Lauf", kundeId: kunde.id },
});
let hier = 0;
let dort = 0;
const ab1 = await aufLaufHoeren(laufId, () => {
hier += 1;
});
const ab2 = await aufLaufHoeren(anderer.id, () => {
dort += 1;
});
await testPrisma.schrittergebnis.update({
where: { id: ergebnisId },
data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id },
});
await warteAuf(() => hier > 0, "Die Meldung am eigenen Lauf");
// Kurz Luft lassen: eine falsche Zustellung käme in derselben Größenordnung.
await new Promise((fertig) => setTimeout(fertig, 200));
expect(dort).toBe(0);
ab1();
ab2();
});
it("zählt die offenen Ströme und gibt sie beim Abmelden frei", async () => {
const { laufId } = await aufbau();
expect(stroemeOffen()).toBe(0);
const ab1 = await aufLaufHoeren(laufId, () => undefined);
const ab2 = await aufLaufHoeren(laufId, () => undefined);
// Zwei Techniker an einem Lauf sind zwei Verbindungen, nicht eine.
expect(stroemeOffen()).toBe(2);
ab1();
expect(stroemeOffen()).toBe(1);
// Zweimal abmelden darf nicht doppelt zählen.
ab1();
expect(stroemeOffen()).toBe(1);
ab2();
expect(stroemeOffen()).toBe(0);
});
});
/** Liest aus einem Strom, bis `bis` im Gelesenen steht. */
async function stromLesen(antwort: Response, bis: (text: string) => boolean): Promise<string> {
const leser = antwort.body?.getReader();
if (!leser) throw new Error("Die Antwort hat keinen Rumpf.");
const entschluessler = new TextDecoder();
let gelesen = "";
const ende = Date.now() + 5_000;
try {
while (Date.now() < ende) {
const { value, done } = await leser.read();
if (value) gelesen += entschluessler.decode(value, { stream: true });
if (bis(gelesen)) return gelesen;
if (done) return gelesen;
}
} finally {
await leser.cancel().catch(() => undefined);
}
return gelesen;
}
describe.runIf(datenbankVerfuegbar)("Der Route Handler", () => {
beforeEach(async () => {
await datenbankLeeren();
vi.mocked(rechtErforderlich).mockReset();
});
afterEach(async () => {
await ereignisseSchliessen();
delete process.env["SSE_HERZSCHLAG_MS"];
delete process.env["SSE_MAX_STROEME"];
});
function angemeldetAls(benutzer: { id: string; rolle: "ADMIN" }): void {
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: benutzer.id, rolle: benutzer.rolle },
sitzung: {},
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
}
function anfrage(accept: string | null): Request {
return new Request("http://127.0.0.1/api/laeufe/x/zustand", {
headers: accept ? { accept } : {},
});
}
it("weist ohne Anmeldung ab, bevor das erste Ereignis fließt", async () => {
const { laufId } = await aufbau();
vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet."));
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
expect(antwort.status).toBe(401);
// Kein Strom: wer kein Recht hat, bekommt keine offene Leitung.
expect(antwort.headers.get("content-type")).toContain("application/json");
expect(stroemeOffen()).toBe(0);
});
it("liefert für einen Lauf eines fremden Kunden keinen Strom", async () => {
const { kunde } = await aufbau();
/*
* Die Mandantensicht greift im Modus DIENSTLEISTUNG und fuer eine Rolle
* unterhalb von REDAKTEUR – im Modus INTERN sieht jeder alles, und das ist
* so gewollt. Der Strom darf diese Grenze nicht umgehen, also wird sie hier
* hergestellt statt behauptet.
*/
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const fremd = await testPrisma.kunde.create({
data: { name: "Fremd GmbH", kuerzel: "FREMD" },
});
const version = await testPrisma.vorlagenVersion.findFirstOrThrow();
const fremderLauf = await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Fremder Lauf", kundeId: fremd.id },
});
// Ein Leser, der nur den eigenen Kunden sieht.
const leser = await testPrisma.benutzer.create({
data: { email: "leser@firma.example", name: "Leser", rolle: "LESER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: leser.id, kundeId: kunde.id },
});
vi.mocked(rechtErforderlich).mockResolvedValue({
benutzer: { id: leser.id, rolle: "LESER" },
sitzung: {},
} as unknown as Awaited<ReturnType<typeof rechtErforderlich>>);
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: fremderLauf.id }),
});
expect(antwort.status).toBe(404);
expect(stroemeOffen()).toBe(0);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("antwortet ohne Accept-Kopf wie bisher mit JSON", async () => {
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage(null), { params: Promise.resolve({ id: laufId }) });
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("application/json");
expect(antwort.headers.get("cache-control")).toBe("no-store");
const stand = (await antwort.json()) as Record<string, unknown>;
expect(stand["status"]).toBe(LaufStatus.OFFEN);
expect(stand["anzahlPflichtOffen"]).toBe(1);
// Seit #033 im Stand, damit ein neues Foto überhaupt eine Änderung ist.
expect(stand["anzahlAnhaenge"]).toBe(0);
expect(stand["anzahlKommentare"]).toBe(0);
// Niemals Befehle: ein generiertes Passwort entsteht bei jeder Auflösung neu.
expect(JSON.stringify(stand)).not.toContain("befehl");
});
it("schickt im Strom sofort den Stand und danach jede Änderung", async () => {
const { laufId, ergebnisId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("text/event-stream");
// Ohne diese Kopfzeile sammelt nginx den Strom und gibt ihn erst am Ende heraus.
expect(antwort.headers.get("x-accel-buffering")).toBe("no");
const rumpf = antwort.body;
if (!rumpf) throw new Error("Kein Rumpf.");
const leser = rumpf.getReader();
const entschluessler = new TextDecoder();
async function lesenBis(bedingung: (text: string) => boolean): Promise<string> {
let gelesen = "";
const ende = Date.now() + 5_000;
while (Date.now() < ende) {
const { value, done } = await leser.read();
if (value) gelesen += entschluessler.decode(value, { stream: true });
if (bedingung(gelesen)) return gelesen;
if (done) break;
}
throw new Error(`Nichts Passendes im Strom. Gelesen: ${JSON.stringify(gelesen)}`);
}
const erstes = await lesenBis((text) => text.includes("event: zustand"));
expect(erstes).toContain('"anzahlPflichtOffen":1');
// Jetzt die Änderung – sie muss von selbst durchkommen.
await testPrisma.schrittergebnis.update({
where: { id: ergebnisId },
data: { zustand: "ERLEDIGT", aktualisiertVonId: admin.id },
});
const zweites = await lesenBis((text) => text.includes('"anzahlPflichtOffen":0'));
expect(zweites).toContain("ERLEDIGT");
await leser.cancel();
});
it("hält die Verbindung mit einem Herzschlag offen", async () => {
// 60 s echt zu warten wäre ein Test, den niemand mehr fährt.
process.env["SSE_HERZSCHLAG_MS"] = "40";
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const antwort = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
const gelesen = await stromLesen(antwort, (text) => text.split(HERZSCHLAG).length - 1 >= 3);
// Drei Herzschläge ohne jede Änderung am Lauf.
expect(gelesen.split(HERZSCHLAG).length - 1).toBeGreaterThanOrEqual(3);
});
it("fällt bei erreichter Obergrenze auf eine gewöhnliche Antwort zurück", async () => {
process.env["SSE_MAX_STROEME"] = "1";
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const erster = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
expect(erster.headers.get("content-type")).toContain("text/event-stream");
await warteAuf(() => stroemeOffen() === 1, "Der erste Strom");
/*
* Der zweite bekommt JSON statt eines Stroms. `EventSource` meldet darauf
* `error` mit `readyState === CLOSED` und versucht es nicht wieder — der
* Client fällt damit von sich aus auf Polling zurück, ohne Sonderfall.
*/
const zweiter = await GET(anfrage("text/event-stream"), {
params: Promise.resolve({ id: laufId }),
});
expect(zweiter.status).toBe(200);
expect(zweiter.headers.get("content-type")).toContain("application/json");
expect(stroemeOffen()).toBe(1);
await erster.body?.cancel();
});
it("gibt den Strom frei, wenn der Browser abbricht", async () => {
const { laufId, admin } = await aufbau();
angemeldetAls(admin as { id: string; rolle: "ADMIN" });
const steuerung = new AbortController();
const antwort = await GET(
new Request("http://127.0.0.1/api/laeufe/x/zustand", {
headers: { accept: "text/event-stream" },
signal: steuerung.signal,
}),
{ params: Promise.resolve({ id: laufId }) },
);
expect(antwort.headers.get("content-type")).toContain("text/event-stream");
await warteAuf(() => stroemeOffen() === 1, "Der Strom");
/*
* Ohne Freigabe wüchse das Register mit jeder geschlossenen Lasche weiter,
* und die Obergrenze zählte irgendwann Verbindungen, die es nicht gibt.
*/
steuerung.abort();
await warteAuf(() => stroemeOffen() === 0, "Die Freigabe des Stroms");
});
});
+34 -2
View File
@@ -5,12 +5,19 @@ import { laufZumAbarbeiten, laufZustand } from "@/server/queries/laeufe";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** /**
* Der Zustand fuer die Aktualisierung alle zehn Sekunden (#013). * Der Zustand, von dem die Aktualisierung lebt (#013, Strom seit #033).
* *
* Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung * Zwei Zusagen haengen daran: zwei Browser am selben Lauf sehen die Aenderung
* des anderen — und die Antwort enthaelt **keine Befehle**, weil ein * des anderen — und die Antwort enthaelt **keine Befehle**, weil ein
* `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker * `{{generiertes_passwort}}` bei jeder Aufloesung neu entsteht und dem Techniker
* sonst unter den Haenden wegtauschte. * sonst unter den Haenden wegtauschte.
*
* Seit #033 kommt der Stand ueber einen Ereignisstrom statt alle zehn Sekunden.
* Am Lesemodell aendert das nichts — **aber an seiner Vollstaendigkeit hängt
* jetzt mehr**: das Polling verglich den ganzen Stand und sah damit auch, was
* kein Trigger meldet. Was hier fehlt, loest nun gar keine Aktualisierung mehr
* aus. Deshalb zaehlt der Test unten die Schluessel namentlich auf, und
* `tests/lauf-ereignisse.test.ts` prueft, dass zu jedem eine Meldung gehoert.
*/ */
async function aufbau() { async function aufbau() {
@@ -137,6 +144,7 @@ describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
expect(alsText).not.toContain("Password"); expect(alsText).not.toContain("Password");
expect(alsText).not.toContain("{{"); expect(alsText).not.toContain("{{");
expect(Object.keys(zustand ?? {}).sort()).toEqual([ expect(Object.keys(zustand ?? {}).sort()).toEqual([
"anzahlAnhaenge",
"anzahlKommentare", "anzahlKommentare",
"anzahlPflichtOffen", "anzahlPflichtOffen",
"geaendertAm", "geaendertAm",
@@ -163,7 +171,31 @@ describe.runIf(datenbankVerfuegbar)("Zustand eines Laufs", () => {
expect(mitKommentar).not.toBe(vorher); expect(mitKommentar).not.toBe(vorher);
await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } }); await testPrisma.lauf.update({ where: { id: lauf.id }, data: { titel: "PC-001 (neuer Titel)" } });
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitKommentar); const mitTitel = JSON.stringify(await laufZustand(lauf.id, benutzer));
expect(mitTitel).not.toBe(mitKommentar);
/*
* Und ein Anhang (#032/#033). Derselbe Grund wie beim Kommentar: ein Foto,
* das der Kollege gerade an einen Schritt gehaengt hat, blieb sonst
* unsichtbar — und seit der Stand nicht mehr alle zehn Sekunden vollstaendig
* verglichen wird, bliebe es das fuer immer.
*/
const ergebnis = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id },
select: { id: true },
});
await testPrisma.anhang.create({
data: {
schrittergebnisId: ergebnis.id,
laufId: lauf.id,
kundeId: lauf.kundeId,
dateiname: "typenschild.png",
medientyp: "image/png",
groesse: 42,
pruefsumme: "c".repeat(64),
},
});
expect(JSON.stringify(await laufZustand(lauf.id, benutzer))).not.toBe(mitTitel);
}); });
it("liefert den Status mit", async () => { it("liefert den Status mit", async () => {