3 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 2d3325ef20 feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
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 zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender
Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine
Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und
Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war.

In keiner Nachricht steht ein Schrittinhalt, und damit auch kein
erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen
dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft
wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt
wurden, so wie #012 es fuer die Druckansicht tat.

Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der
Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index:
der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der
Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880
Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der
Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn
hinter einem Lastverteiler laufen zwei Instanzen.

Eine stille Warteschlange ist schlimmer als keine: Verwaltung →
Zustellungen zeigt mit Grund, was hinausging und was nicht.

Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine
abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem
Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:22:20 +02:00
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
67 changed files with 7196 additions and 255 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
+47
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,36 @@ 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
- **Benachrichtigungen per Mail und Webhook.** Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand
die Übersicht öffnete; ein überfälliger überhaupt nicht. Vier Anlässe gehen jetzt hinaus —
**zugewiesen**, **überfällig**, **abgeschlossen** und **Verzeichnisabgleich fehlgeschlagen** —, je
Person abschaltbar, und **niemand bekommt Post über die eigene Tat**. Mail über SMTP (Zugangsdaten
verschlüsselt wie das LDAP-Bind-Passwort, mit „Testmail schicken“), dazu optional ein Webhook mit
HMAC-SHA256-Signatur im Kopf `X-Laufzettel-Signatur`.
Zugestellt wird aus einer **Warteschlange in der Datenbank**, nicht im Anfrage-Pfad: ein hängender
Mailserver darf kein Abhaken blockieren. Fehlschläge werden nach 1, 5, 30 und 120 Minuten
wiederholt und bleiben danach als **aufgegeben** sichtbar — unter Verwaltung → Zustellungen, denn
eine stille Warteschlange ist schlimmer als keine.
**In keiner Nachricht steht ein Schrittinhalt**, und damit auch kein erzeugtes Passwort: geprüft
über alle Rumpftexte, die wirklich verschickt wurden (#034)
- **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
+29 -11
View File
@@ -38,6 +38,7 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` | | DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` | | — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` | | — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
| — (ergänzt) | `Zustellung` | | |
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` · Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
`LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` · `LaufStatus {OFFEN IN_ARBEIT ABGESCHLOSSEN ARCHIVIERT}` ·
@@ -45,7 +46,9 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENS
`SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) · `SchrittTyp {HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL}` (später `AKTION`) ·
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` · `VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` · `GeraeteTyp {PC NOTEBOOK SERVER FIREWALL SWITCH DRUCKER RAUM SONSTIGES}` ·
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}`. `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. `SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -56,7 +59,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.
``` ```
@@ -97,9 +101,11 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
ist darstellbar. ist darstellbar.
- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei - **Regeln stehen in der Datenbank, nicht nur im Service.** CHECK-Bedingungen, partielle
partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch Eindeutigkeitsindizes, zusammengesetzte Fremdschlüssel und Trigger halten fest, was auch
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt.
`tests/datenbankregeln.test.ts` zählt sie **namentlich** auf — dort steht die jeweils aktuelle
Liste, und genau deshalb steht hier keine Zahl mehr: sie lief dreimal der Wirklichkeit nach. Die Mandantengrenze ist
einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines einer davon: ein Lauf kann strukturell kein Gerät, keine Person und keine Abteilung eines
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung. fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
@@ -121,6 +127,12 @@ Zwei Obergrenzen, die nicht angehoben werden dürfen:
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
(pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr). (pnpm 12 liest das `pnpm`-Feld in `package.json` nicht mehr).
**`minimumReleaseAgeExclude` bleibt leer.** pnpm 12 nimmt Pakete erst nach einer Wartezeit an — ein
Schutz gegen ein kompromittiertes Release, das Stunden später zurückgezogen wird. Trägt `pnpm add`
dort von sich aus eine Ausnahme ein, ist das kein Formfehler, sondern die Meldung „diese Version ist
frisch von heute“. Dann gehört **eine abgelagerte Version** gepinnt, nicht die Ausnahme committet.
`nodemailer` steht deshalb exakt auf `10.0.10` und nicht auf der neuesten Fassung (#034).
## Next 16 ## Next 16
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`, `middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
@@ -176,15 +188,21 @@ speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
## Vor jedem Commit ## Vor jedem Commit
```bash ```bash
docker compose -f docker-compose.test.yml up -d --wait ldap docker compose -f docker-compose.test.yml up -d --wait ldap mailpit
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
``` ```
**Der LDAP-Container gehört in die erste Zeile.** Ohne ihn überspringt die Suite **22 Tests** — den **Die beiden Container gehören in die erste Zeile.** Ohne sie überspringt die Suite Tests, sagt das
ganzen Verzeichnis-Abgleich (nachgezählt: 81 statt 59 in `tests/abgleich.test.ts` und auf stderr — und sieht trotzdem grün aus. Wer nur diesem Abschnitt folgte, hätte das Betroffene nie
`tests/verzeichnis.test.ts`). Sie sagt das auf stderr, aber der Lauf sieht grün aus, und wer nur geprüft. In CI bricht sie stattdessen ab, absichtlich.
diesem Abschnitt folgte, hat den Abgleich nie geprüft. In CI bricht sie stattdessen ab, absichtlich.
Aufgefallen bei der Abnahme zu #019. | Container | Fehlt er, fehlen | Betroffen |
|---|---|---|
| `ldap` | **22 Tests** (nachgezählt: 81 statt 59) | der ganze Verzeichnis-Abgleich, `tests/abgleich.test.ts` und `tests/verzeichnis.test.ts` |
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden.
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert `format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+4 -3
View File
@@ -45,12 +45,13 @@ openssl rand -base64 48
### Qualitätsschranken ### Qualitätsschranken
```bash ```bash
docker compose -f docker-compose.test.yml up -d --wait ldap # sonst fehlen 22 Tests docker compose -f docker-compose.test.yml up -d --wait ldap mailpit # sonst fehlen 25 Tests
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test && pnpm pruefe:doku
``` ```
Der LDAP-Container ist keine Zierde: ohne ihn überspringt die Suite den ganzen Verzeichnis-Abgleich Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen
— sie sagt das auf stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab. Verzeichnis-Abgleich (22 Tests), ohne `mailpit` den echten Mailversand (3 Tests) — sie sagt das auf
stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits. Ein Pre-Commit-Hook erzwingt Lint und Format, ein Commit-Msg-Hook Conventional Commits.
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
+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"
+24
View File
@@ -17,6 +17,30 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Der Mailfaenger fuer die Benachrichtigungen (#034). Mailpit nimmt SMTP auf
# 1025 an und zeigt die Nachrichten ueber eine HTTP-Schnittstelle auf 8025 —
# darueber liest der Test, was wirklich ankam.
#
# **Ein echter SMTP-Dialog, keine Attrappe.** Eine abgefangene
# `sendMail`-Funktion haette in #034 nichts bewiesen: dass Betreff und
# Empfaenger stimmen, zeigt erst eine Nachricht, die durch Verbindungsaufbau,
# EHLO und DATA gegangen ist. Ohne Anmeldung und ohne TLS, weil der Test den
# Dialog prueft und nicht die Verschluesselung.
mailpit:
image: axllent/mailpit:latest
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "true"
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
ports:
- "127.0.0.1:1025:1025"
- "127.0.0.1:8025:8025"
healthcheck:
# Mailpit bringt kein curl mit; `/livez` ueber den eigenen Client.
test: ["CMD", "/mailpit", "readyz"]
interval: 5s
timeout: 5s
retries: 20
# Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen # Das Verzeichnis fuer den Abgleich (#016). OpenLDAP mit einem AD-aehnlichen
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID, # Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit, # userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
+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
+49
View File
@@ -169,6 +169,55 @@ nicht archivieren.
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden. lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
## Benachrichtigungen
Ein zugewiesener Lauf fiel früher nur auf, wenn jemand die Übersicht öffnete. Ein überfälliger
überhaupt nicht. Laufzettel meldet deshalb vier Dinge — und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, einmal je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle Administratoren |
**Über die eigene Tat bekommt niemand Post.** Wer selbst zuweist, abschließt oder abhakt, erfährt es
nicht per Mail — das ist der häufigste Grund, Benachrichtigungen ganz abzuschalten.
**In der Nachricht stehen Titel, Status, Gerät, Person, Frist und ein Link.** Keine Schrittinhalte,
und damit auch kein erzeugtes Passwort. Das Passwort entsteht bei jeder Anzeige neu; eine Mail
dagegen bleibt für immer in einem Postfach.
### Einrichten
Unter **Verwaltung → Einstellungen**, Abschnitt **Benachrichtigungen**: Host, Port, Benutzer,
Passwort und Absender des Mailservers. Dann **Speichern**, dann **Testmail schicken** — die geht
sofort hinaus, nicht über die Warteschlange, und ein Fehler erscheint im Klartext.
Daneben steht ein **Webhook**: dieselben vier Anlässe als JSON an eine Adresse, mit Signatur. Das ist
für Ticketsysteme und Chat-Räume gedacht; die Einzelheiten stehen in
[`docs/betrieb/benachrichtigungen.md`](../betrieb/benachrichtigungen.md).
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist.** Leer heißt „unverändert lassen“ —
sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Abschalten
Unter **Verwaltung → Benutzer** schaltet der **Mail**-Knopf an einer Person die Benachrichtigungen
ab. Das geht auch für das eigene Konto. Wer abgeschaltet hat, bekommt nichts und löst auch keinen
Fehler aus.
### Zustellungen
Unter **Verwaltung → Zustellungen** steht, was hinausgegangen ist und was nicht. Schlägt eine
Zustellung fehl, versucht Laufzettel es nach 1, 5, 30 und 120 Minuten erneut; nach dem fünften
Fehlschlag bleibt sie als
**aufgegeben** stehen.
**Da hinzusehen lohnt, wenn jemand sagt, er bekomme keine Mails.** Fast immer ist das SMTP-Passwort
abgelaufen — und ohne diese Seite merkt es wochenlang niemand, weil die Läufe weiter funktionieren.
Ganz oben steht ein Hinweis, sobald etwas aufgegeben wurde.
## Protokoll ## Protokoll
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen, Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
+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
+75 -10
View File
@@ -185,21 +185,86 @@ 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.
### Warum die Benachrichtigungen in einer Warteschlange liegen
Vier Anlässe gehen seit #034 hinaus: zugewiesen, überfällig, abgeschlossen, Abgleich fehlgeschlagen.
Drei Entscheidungen prägen das.
**Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein hängender Mailserver darf kein
Abhaken blockieren. Der Dienst schreibt eine Zeile in `Zustellung` — eine Einfügung in dieselbe
Datenbank, die der Vorgang ohnehin anfasst —, und ein Zeitplan im selben Prozess schickt sie. Das
Einreihen passiert **nach** der Transaktion und mit abgefangenem Fehler: ein Abhaken darf nicht
daran scheitern, dass eine Benachrichtigung nicht eingereiht werden konnte.
**Betreff und Rumpf stehen fertig in der Zeile.** Das kostet Platz und ist es wert: eine Meldung
sagt, was *damals* war. Baute der Zeitplan den Text erst beim Senden, stünde in einer Mail von heute
der Stand von jetzt — und in einer aufgegebenen Zustellung von nächster Woche etwas völlig anderes.
**In keiner Meldung steht ein Schrittinhalt.** Darüber wacht `src/lib/meldungen.ts`: die Funktionen
dort nehmen **nur** Kopfdaten, der Typ lässt nichts anderes zu. Eine Mail mit dem Lauf-Inhalt wäre
genau die Lücke, die dieses Projekt an jeder anderen Stelle geschlossen hat — das erzeugte Passwort
wird nirgends gespeichert, aber eine Mail liegt für immer in einem Postfach, oft auf einem Server,
der nicht im Haus steht. `tests/benachrichtigungen.test.ts` prüft es zusätzlich über **alle**
Rumpftexte, die wirklich verschickt wurden, so wie #012 es für die Druckansicht tat.
Zwei Dinge, die ohne Datenbank nicht gehalten hätten. Die **Einmaligkeit der
Überfälligkeitsmeldung** liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute
nach, und ohne ihn bekäme der Zuständige für einen Lauf, der über das Wochenende steht, 2880 Mails —
jede davon für sich korrekt, kein Test rot. Und die **Sperre** der Warteschlange liegt in derselben
Tabelle wie die des Abgleichs: hinter einem Lastverteiler laufen zwei Instanzen, und ein `let laeuft
= false` im Modul wüsste nichts von der zweiten.
**Das Polling-Prinzip gilt auch hier nicht umgekehrt:** ein Kommentar und ein angehängtes Foto lösen
*keine* Mail aus. Beides sieht die Live-Aktualisierung sofort (Abschnitt 5d); eine Mail dafür wäre
Lärm. Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich.
## 5e. Der Aufkleber ist die Schnittstelle ## 5e. Der Aufkleber ist die Schnittstelle
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
+2
View File
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang - **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und - **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
- **[Benachrichtigungen](benachrichtigungen.md)** — SMTP einrichten, den Webhook prüfen, die
Signatur nachrechnen, und wo man nachsieht, wenn keine Mail mehr ankommt
## Wo sonst noch etwas steht ## Wo sonst noch etwas steht
+170
View File
@@ -0,0 +1,170 @@
# Benachrichtigungen einrichten
Laufzettel meldet vier Dinge, und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
## Was gebraucht wird
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
## Mail über SMTP
Unter **Admin → Einstellungen → Benachrichtigungen**:
| Feld | Was hineingehört |
|---|---|
| Host | `mail.firma.de` — der Mailserver des Hauses |
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
| TLS verlangen | an, außer der Server kann es wirklich nicht |
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
Liste.
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Wenn die Testmail nicht ankommt
| Meldung | Ursache |
|---|---|
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
## Webhook
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
```json
{
"anlass": "LAUF_ABGESCHLOSSEN",
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
"link": "https://laufzettel.firma.de/laeufe/cmu…",
"laufId": "cmu…",
"verzeichnisquelleId": null,
"zeitpunkt": "2026-10-05T08:00:00.000Z"
}
```
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
### Die Signatur nachrechnen
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
```
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
```
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function pruefen(geheimnis, rumpf, kopf) {
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
// Zeitkonstant vergleichen, nicht mit ===
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
}
```
Drei Dinge, die dabei schiefgehen:
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
vor jeder Verarbeitung lesen.
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
Der Abstand gehört in **beide** Richtungen geprüft.
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
## Die Warteschlange
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
zweiter Dienst, kein Systemd-Timer.
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
> niemand, weil jeder Lauf weiter funktioniert.
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
### Mehrere Instanzen
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
## Je Person abschalten
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
abzustellen ist der häufigste Wunsch überhaupt.
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
deaktiviertes Konto ebenso.
## Was nicht gemeldet wird
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
- **Ein einzelner abgehakter Schritt.** Dasselbe.
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
ab, was im Betrieb wirklich fehlt.
## Prüfen, dass es läuft
```bash
# Steht etwas in der Warteschlange, das nicht hinausgeht?
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
```
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.
+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. |
+215 -2
View File
@@ -24,12 +24,21 @@ 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 |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
| | **Summe** | **53 h 35 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 | 18 h 15 min | in Arbeit |
--- ---
@@ -1089,3 +1098,207 @@ 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.
---
## 2026-10-05 · Sitzung 19 · 10:45–14:15
**Issue:** #034 (M21)
**Version:** 0.2.0 (unveröffentlicht)
### Erledigt
- **Vier Anlässe gehen hinaus**, per Mail über SMTP und optional per Webhook mit
HMAC-SHA256-Signatur. Je Person abschaltbar, und niemand bekommt Post über die eigene Tat.
- **Eine Warteschlange in der Datenbank**, abgearbeitet von einem eigenen Zeitplan im selben
Prozess. Fehlschläge nach 1, 5, 30 und 120 Minuten wiederholt, danach sichtbar aufgegeben.
- **Verwaltung → Zustellungen** zeigt, was hinausging und was nicht, mit Grund.
- 1302 Vitest-Tests (vorher 1266), davon 36 neue — **gegen einen echten SMTP-Fänger** (mailpit),
nicht gegen eine abgefangene Funktion.
- Im gebauten Stand gefahren: SMTP eingetragen, Testmail angekommen, ein überfälliger Lauf angelegt,
und der Zeitplan hat die Meldung **von selbst** verschickt.
### Entscheidungen
- **Eingereiht im Anfrage-Pfad, gesendet vom Zeitplan.** Ein hängender Mailserver darf kein Abhaken
blockieren. Das Einreihen passiert nach der Transaktion und mit abgefangenem Fehler.
- **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was *damals* war; würde der
Text erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
- **Die Texte liegen in `src/lib/meldungen.ts` und nehmen nur Kopfdaten.** Der Typ lässt keinen
Schrittinhalt zu — das ist die Absicht, und es ist die einzige Stelle, die man dafür ändern müsste.
- **Die Sperre wurde aus dem Abgleich herausgezogen** nach `services/sperre.ts`. Zwei Fassungen
derselben Mechanik wären die schlechteste aller Lösungen.
- **Die Mandantengrenze gilt auch für Empfänger.** Ohne diese Prüfung wäre die Benachrichtigung der
eine Weg nach draußen, den `sichtbareKundenIds` nicht abdeckt.
### Probleme
- **pnpm wollte eine Sicherheitsregel umgehen, und ich hätte es fast committet.** `pnpm add
nodemailer` trug von sich aus `minimumReleaseAgeExclude: nodemailer@10.0.15` in
`pnpm-workspace.yaml` ein — die Version war **vom selben Tag**. Das ist kein Formfehler, sondern
genau die Warnung, vor der die Regel schützt. Gepinnt ist jetzt `10.0.10` vom 14. September, die
Ausnahme ist weg, und die Konvention steht in `CLAUDE.md`. Aufgefallen beim Durchsehen des Diffs,
nicht durch einen Test — eine Zeile in einer Datei, die man leicht übersieht.
- **Drei Tests waren stillschweigend übersprungen.** `it.runIf(mailpitDa)` wird beim **Einlesen** der
Datei ausgewertet; mein Flag stand in `beforeAll` und kam zu spät. Der erste Lauf meldete
„3 skipped“, war grün — und hatte den echten SMTP-Dialog nie geprüft. Genau das Muster, das
`ldap` in #019 schon einmal gekostet hat. `mailpit` steht jetzt im Tor vor dem Commit, und
`pruefe:doku` erzwingt es; gegengeprobt.
- **Die Wartezeiten waren um eins verschoben.** Nach dem ersten Fehlschlag wartete meine erste
Fassung fünf Minuten statt einer. Der Test hat es sofort gezeigt — und die Umbenennung des
Parameters auf `versucheGesamt` macht die Semantik jetzt lesbar.
- **Zwei Knöpfe „Speichern“ auf einer Seite**, beim Durchklicken gefunden. Der neue heißt jetzt
„Benachrichtigungen speichern“.
- **Drei eigene Testfehler**, keine Produktfehler: die `Verzeichnisquelle` hat `url`, nicht
`host`/`port`; `Lauf` mit Status `ABGESCHLOSSEN` braucht `abgeschlossenAm` (die Datenbank verlangt
es); und ein fester Testzeitpunkt lag **vor** der echten Einreihung, sodass der Durchgang die
Zeile gar nicht erst anfasste.
### Nächster Schritt
- **M22 (#035)** Mitarbeiter-Selbstauskunft — dort kommt auch die Profilseite, auf der jeder seine
eigenen Benachrichtigungen abschalten kann.
- **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.
+70 -21
View File
@@ -1,10 +1,10 @@
# #034 M21 – Benachrichtigungen per Mail und Webhook # #034 M21 – Benachrichtigungen per Mail und Webhook
**Status:** offen **Status:** erledigt
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54) **Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
**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 30 min
## Ziel ## Ziel
@@ -14,44 +14,93 @@ Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet.
## Zu entscheiden ## Zu entscheiden
- [ ] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner - [x] **Nur diese vier Ereignisse, je Empfänger abschaltbar** — Empfehlung. Ein allgemeiner
Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb Benachrichtigungsbaukasten ist eine Phase für sich; vier Ereignisse decken ab, was im Betrieb
wirklich fehlt. wirklich fehlt.
- [ ] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den - [x] **Mail über SMTP, keine Dienstanbindung.** Eine selbst gehostete Anwendung soll an den
Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das Mailserver des Hauses können, und das ist SMTP. Zugangsdaten verschlüsselt wie das
LDAP-Bind-Passwort. LDAP-Bind-Passwort.
- [ ] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender - [x] **Zustellung in einer Warteschlange in der Datenbank**, nicht im Anfrage-Pfad. Ein hängender
Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich Mailserver darf kein Abhaken blockieren. Dieselbe Sperre wie beim Abgleich
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken. (`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
## Aufgaben ## Aufgaben
- [ ] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler, - [x] Modell `Zustellung`: Ereignis, Empfänger, Weg (MAIL | WEBHOOK), Versuche, letzter Fehler,
Zustand; mit Index auf das, was der Zeitplan abholt Zustand; mit Index auf das, was der Zeitplan abholt
- [ ] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort - [x] SMTP-Einstellungen unter Admin → Einstellungen: Host, Port, TLS, Benutzer, Passwort
(verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“ (verschlüsselt, nie in einer Antwort), Absender, „Testmail schicken“
- [ ] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`, - [x] Webhook: Ziel-URL, Geheimnis, **HMAC-SHA256 über den Rumpf** im Kopf `X-Laufzettel-Signatur`,
dazu ein Zeitstempel gegen Wiedereinspielen dazu ein Zeitstempel gegen Wiedereinspielen
- [ ] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar - [x] Wiederholung mit wachsendem Abstand (1, 5, 30, 120 Minuten), dann aufgeben und sichtbar
machen. Eine stille Warteschlange ist schlimmer als keine machen. Eine stille Warteschlange ist schlimmer als keine
- [ ] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter - [x] Überfälligkeit erkennt der Zeitplan, der schon läuft (`node-cron` im Prozess) — kein zweiter
Dienst Dienst
- [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler - [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
- [ ] Admin: die letzten Zustellungen mit Zustand und Grund - [x] Admin: die letzten Zustellungen mit Zustand und Grund
- [ ] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre - [x] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht genau die Lücke, die das Projekt an jeder anderen Stelle geschlossen hat. Die Nachricht
enthält Titel, Status und einen Link — **keine Schrittinhalte** enthält Titel, Status und einen Link — **keine Schrittinhalte**
- [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen - [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
- [ ] Anleitung Kapitel 07 - [x] Anleitung Kapitel 07
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig, - [x] Test gegen einen echten SMTP-Fänger im Container (mailpit): zugewiesen, überfällig,
abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger abgeschlossen, Abgleich fehlgeschlagen — vier Mails, richtige Empfänger
- [ ] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit - [x] Test gegen einen lokalen Empfänger: die Signatur lässt sich mit dem Geheimnis nachrechnen, mit
einem falschen nicht einem falschen nicht
- [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar - [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
- [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal - [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
- [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie - [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
in #012 in #012
- [ ] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts - [x] Test: ein Benutzer mit abgeschalteter Benachrichtigung bekommt nichts
## Beim Umsetzen dazugekommen
- **Die Sperre wurde aus dem Abgleich herausgezogen.** `mitAbgleichSperre` lag in
`services/abgleich.ts`; die Warteschlange braucht dieselbe Mechanik, und zwei Fassungen davon
wären die schlechteste aller Lösungen. Sie heißt jetzt `mitSperre` in `services/sperre.ts`. Die
Tabelle behält den Namen `AbgleichSperre` — ein Umbenennen wäre eine Migration, die nichts
verbessert, und der Schlüssel sagt ohnehin, wer sperrt.
- **Niemand bekommt Post über die eigene Tat.** Steht nicht im Issue, ist aber der häufigste Grund,
Benachrichtigungen ganz abzuschalten. Der Handelnde fällt immer aus der Empfängerliste.
- **Die Mandantengrenze gilt auch für Empfänger.** Eine Meldung trägt Lauftitel und Gerätenamen;
ohne diese Prüfung wäre die Benachrichtigung der eine Weg nach draußen, den `sichtbareKundenIds`
nicht abdeckt. Ein Benutzer, der den Kunden des Laufs nicht sehen darf, bekommt keine Mail — mit
eigenem Test.
- **Die Einmaligkeit der Überfälligkeitsmeldung liegt in der Datenbank**, als partieller eindeutiger
Index. Der Zeitplan sieht jede Minute nach; ohne ihn bekäme der Zuständige für einen Lauf, der
über das Wochenende steht, **2880 Mails** — und jede einzelne davon wäre für sich korrekt, kein
Test rot. Gegenprobe gefahren: ohne den Index werden zwei Tests rot.
- **Fünf Versuche, nicht vier.** Die vier Wartezeiten aus dem Issue (1, 5, 30, 120 Minuten) liegen
*zwischen* den Versuchen — der fünfte Fehlschlag gibt auf. Meine erste Fassung indizierte um eins
verschoben und wartete nach dem ersten Fehlschlag fünf Minuten; der Test hat es sofort gezeigt.
- **Ein eigener Zeitplan, nicht derselbe wie der Abgleich.** Der Mailversand darf nicht warten, weil
gerade ein großes Verzeichnis gelesen wird. Zwei Takte sind billig.
- **`mailpit` gehört ins Tor vor dem Commit.** Ohne den Container überspringt die Suite **3 Tests**
(gemessen: 36 statt 33) — genau die, die den echten SMTP-Dialog prüfen. `CLAUDE.md`, das README
und `scripts/pruefe-doku.mjs` nennen ihn jetzt, und die Prüfung ist gegengeprobt: ohne ihn im
Block wird `pnpm pruefe:doku` rot.
- **`it.runIf(mailpitDa)` wird beim Einlesen der Datei ausgewertet.** Das Flag stand in `beforeAll`
und kam zu spät: der erste Lauf meldete „3 skipped“, war grün und hatte den Mailversand nie
geprüft. Jetzt wird auf Modulebene geprüft, wie bei `datenbankVerfuegbar`.
- **pnpm wollte eine Mindestalter-Sperre umgehen.** `pnpm add nodemailer` trug von sich aus
`minimumReleaseAgeExclude: nodemailer@10.0.15` ein — die Version war **vom selben Tag**. Das ist
kein Formfehler, sondern genau die Warnung, vor der die Regel schützt: ein frisches Release eines
Mailpakets ungeprüft einzubauen. Gepinnt ist jetzt `10.0.10` vom 14. September, die Ausnahme ist
weg, und die Konvention steht in `CLAUDE.md`.
- **Zwei Knöpfe „Speichern“ auf einer Seite.** Beim Durchklicken aufgefallen: die Einstellungsseite
hatte zwei gleich benannte. Der neue heißt jetzt „Benachrichtigungen speichern“ — für einen
Menschen genauso verwirrend wie für einen Test.
- **Die Testmail geht direkt hinaus, nicht über die Warteschlange.** Ein Knopf, der „eingereiht“
meldet und den Fehler eine Minute später woanders zeigt, prüft nichts.
- **Der Mail-Schalter gilt auch für das eigene Konto**, anders als „aktiv“: sich selbst zu
deaktivieren wäre ein Eigentor, seine eigene Post abzustellen der häufigste Wunsch überhaupt. Eine
**Profilseite**, auf der das jeder selbst könnte, fehlt noch — sie kommt mit der Selbstauskunft
(#035). Bis dahin stellt ein Administrator es um, und so steht es in der Anleitung.
- **Im gebauten Stand wirklich gefahren:** SMTP eingetragen, Testmail bei mailpit angekommen, ein
überfälliger Lauf angelegt — und der Zeitplan hat die Meldung **von selbst** verschickt, ohne
Zutun. Dazu geprüft: das Passwort steht nicht im Quelltext der Seite, Speichern ohne Passwortfeld
behält es, die Zustellungsliste zeigt keinen Rumpf, und 390 px ohne waagerechtes Scrollen auf allen
drei Seiten.
+4 -4
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) | erledigt | v0.2.0 | 10 h | 3 h 30 min |
| #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** | **53 h 35 min** |
+2
View File
@@ -54,6 +54,7 @@
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.3.6", "next": "16.3.6",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"nodemailer": "10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"pg": "^8.23.0", "pg": "^8.23.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -72,6 +73,7 @@
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.13.6", "@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
+19
View File
@@ -220,6 +220,9 @@ importers:
node-cron: node-cron:
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.6.0 version: 4.6.0
nodemailer:
specifier: 10.0.10
version: 10.0.10
openid-client: openid-client:
specifier: ^6.8.8 specifier: ^6.8.8
version: 6.8.8 version: 6.8.8
@@ -269,6 +272,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.13.6 specifier: ^24.13.6
version: 24.13.6 version: 24.13.6
'@types/nodemailer':
specifier: ^8.0.2
version: 8.0.2
'@types/pg': '@types/pg':
specifier: ^8.23.1 specifier: ^8.23.1
version: 8.23.1 version: 8.23.1
@@ -2321,6 +2327,9 @@ packages:
'@types/node@24.13.6': '@types/node@24.13.6':
resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==}
'@types/nodemailer@8.0.2':
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
'@types/pg@8.23.1': '@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -4113,6 +4122,10 @@ packages:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@10.0.10:
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
engines: {node: '>=20.0.0'}
oauth4webapi@3.8.8: oauth4webapi@3.8.8:
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
@@ -6880,6 +6893,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/nodemailer@8.0.2':
dependencies:
'@types/node': 24.13.6
'@types/pg@8.23.1': '@types/pg@8.23.1':
dependencies: dependencies:
'@types/node': 24.13.6 '@types/node': 24.13.6
@@ -9011,6 +9028,8 @@ snapshots:
node-releases@2.0.57: {} node-releases@2.0.57: {}
nodemailer@10.0.10: {}
oauth4webapi@3.8.8: {} oauth4webapi@3.8.8: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
@@ -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();
@@ -0,0 +1,143 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Benachrichtigungen per Mail und Webhook (#034).
CREATE TYPE "Zustellweg" AS ENUM ('MAIL', 'WEBHOOK');
CREATE TYPE "Zustellzustand" AS ENUM ('OFFEN', 'GESENDET', 'FEHLGESCHLAGEN', 'AUFGEGEBEN');
CREATE TYPE "Meldeanlass" AS ENUM (
'LAUF_ZUGEWIESEN',
'LAUF_UEBERFAELLIG',
'LAUF_ABGESCHLOSSEN',
'ABGLEICH_FEHLGESCHLAGEN'
);
-- Je Empfaenger abschaltbar.
ALTER TABLE "Benutzer" ADD COLUMN "meldungenAktiv" BOOLEAN NOT NULL DEFAULT true;
-- SMTP und Webhook gehoeren zur einen Einstellungszeile.
ALTER TABLE "Einstellung"
ADD COLUMN "smtpHost" TEXT,
ADD COLUMN "smtpPort" INTEGER,
ADD COLUMN "smtpTls" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "smtpBenutzer" TEXT,
ADD COLUMN "smtpPasswortVerschluesselt" TEXT,
ADD COLUMN "smtpAbsender" TEXT,
ADD COLUMN "webhookUrl" TEXT,
ADD COLUMN "webhookGeheimnisVerschluesselt" TEXT;
CREATE TABLE "Zustellung" (
"id" TEXT NOT NULL,
"anlass" "Meldeanlass" NOT NULL,
"weg" "Zustellweg" NOT NULL,
"empfaenger" TEXT NOT NULL,
"betreff" TEXT NOT NULL,
"rumpf" TEXT NOT NULL,
"zustand" "Zustellzustand" NOT NULL DEFAULT 'OFFEN',
"versuche" INTEGER NOT NULL DEFAULT 0,
"naechsterVersuchAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzterFehler" TEXT,
"gesendetAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"benutzerId" TEXT,
"laufId" TEXT,
"verzeichnisquelleId" TEXT,
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
);
-- Der Index, auf dem der Zeitplan arbeitet: er fragt jede Minute nach offenen
-- und faelligen Zeilen. Ohne ihn waere das ein vollstaendiger Durchlauf der
-- Tabelle, die mit der Zeit nur waechst.
CREATE INDEX "Zustellung_zustand_naechsterVersuchAm_idx"
ON "Zustellung"("zustand", "naechsterVersuchAm");
CREATE INDEX "Zustellung_erstelltAm_idx" ON "Zustellung"("erstelltAm");
CREATE INDEX "Zustellung_laufId_idx" ON "Zustellung"("laufId");
CREATE INDEX "Zustellung_benutzerId_idx" ON "Zustellung"("benutzerId");
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_benutzerId_fkey" FOREIGN KEY ("benutzerId")
REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "Zustellung_laufId_fkey" FOREIGN KEY ("laufId")
REFERENCES "Lauf"("id") ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "Zustellung_verzeichnisquelleId_fkey" FOREIGN KEY ("verzeichnisquelleId")
REFERENCES "Verzeichnisquelle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Regeln, die auch dann gelten, wenn jemand an der Service-Schicht vorbei
-- schreibt.
-- ---------------------------------------------------------------------------
-- Ein Empfaenger ohne Inhalt ist eine Zeile, die nie ankommen kann.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_empfaenger_nicht_leer"
CHECK (length(btrim("empfaenger")) > 0);
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_versuche_nicht_negativ" CHECK ("versuche" >= 0);
-- „Gesendet“ und „wann“ gehoeren zusammen, in beide Richtungen. Dieselbe Form
-- wie bei `Lauf_abgeschlossen_mit_zeitpunkt`: ein Zustand ohne Zeitpunkt macht
-- jede spaetere Auswertung zur Schaetzung.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_gesendet_mit_zeitpunkt"
CHECK (("zustand" = 'GESENDET') = ("gesendetAm" IS NOT NULL));
-- Eine Mail geht an eine Person, ein Webhook nicht. Ohne das koennte eine
-- Webhook-Zeile an einem Benutzer haengen und in dessen Mailverlauf auftauchen.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_webhook_ohne_benutzer"
CHECK ("weg" <> 'WEBHOOK' OR "benutzerId" IS NULL);
-- Jeder Anlass haengt an dem, worum es geht: die drei Lauf-Anlaesse an einem
-- Lauf, der Abgleich an einer Quelle. Eine Meldung ohne Bezug waere ein Satz
-- ohne Gegenstand — und der Link in der Mail zeigte ins Leere.
ALTER TABLE "Zustellung"
ADD CONSTRAINT "Zustellung_bezug_passt_zum_anlass"
CHECK (
("anlass" = 'ABGLEICH_FEHLGESCHLAGEN' AND "verzeichnisquelleId" IS NOT NULL AND "laufId" IS NULL)
OR ("anlass" <> 'ABGLEICH_FEHLGESCHLAGEN' AND "laufId" IS NOT NULL AND "verzeichnisquelleId" IS NULL)
);
-- Verschluesselt, nicht im Klartext – dieselbe Pruefung wie beim
-- LDAP-Bind-Passwort. `v<n>.` ist das Praefix aus `src/lib/geheimnisse.ts`.
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_smtp_passwort_verschluesselt"
CHECK ("smtpPasswortVerschluesselt" ~ '^v[0-9]+\.');
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_webhook_geheimnis_verschluesselt"
CHECK ("webhookGeheimnisVerschluesselt" ~ '^v[0-9]+\.');
ALTER TABLE "Einstellung"
ADD CONSTRAINT "Einstellung_smtp_port_bereich"
CHECK ("smtpPort" IS NULL OR ("smtpPort" BETWEEN 1 AND 65535));
-- ---------------------------------------------------------------------------
-- Und die Regel, die der Zeitplan braucht.
-- ---------------------------------------------------------------------------
/*
* **Ueberfaellig wird je Lauf und Empfaenger genau einmal gemeldet.**
*
* Der Zeitplan sieht jede Minute nach. Ohne diesen Index bekaeme der
* Zustaendige fuer einen Lauf, der ueber das Wochenende ueberfaellig steht,
* 2880 Mails — und zwar ohne dass irgendein Test rot waere, denn jede einzelne
* davon ist korrekt. Der Dienst prueft dasselbe noch einmal, nur mit besserer
* Meldung.
*
* Partiell, weil es nur fuer diesen einen Anlass gilt: „abgeschlossen“ darf nach
* einem Zurueckholen und erneuten Abschliessen ein zweites Mal hinausgehen, und
* „zugewiesen“ bei jeder neuen Zuweisung.
*/
CREATE UNIQUE INDEX "Zustellung_ueberfaellig_einmal"
ON "Zustellung"("laufId", "empfaenger")
WHERE "anlass" = 'LAUF_UEBERFAELLIG';
+143
View File
@@ -110,10 +110,99 @@ model Einstellung {
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile /// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde. /// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
schnellstartGepflegt Boolean @default(false) schnellstartGepflegt Boolean @default(false)
/// SMTP fuer die Benachrichtigungen (#034). Leerer Host heisst: keine Mail.
/// Kein Dienstanbieter, sondern SMTP — eine selbst gehostete Anwendung soll an
/// den Mailserver des Hauses koennen.
smtpHost String?
smtpPort Int?
/// `true` = StartTLS oder implizites TLS (Port 465). Klartext nur, wenn jemand
/// das ausdruecklich abschaltet.
smtpTls Boolean @default(true)
smtpBenutzer String?
/// AES-256-GCM aus APP_SECRET, wie `Verzeichnisquelle.bindPasswortVerschluesselt`.
/// Erreicht **nie** eine Antwort.
smtpPasswortVerschluesselt String?
smtpAbsender String?
/// Ein Webhook-Ziel fuer dieselben vier Anlaesse. Leere URL heisst: keiner.
webhookUrl String?
/// Geheimnis fuer die HMAC-SHA256-Signatur, verschluesselt wie oben.
webhookGeheimnisVerschluesselt String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
} }
/// Wie eine Meldung hinausgeht (#034).
enum Zustellweg {
MAIL
WEBHOOK
}
/// Wo eine Zustellung steht (#034).
enum Zustellzustand {
OFFEN
GESENDET
/// Fehlgeschlagen, wird aber noch einmal versucht.
FEHLGESCHLAGEN
/// Endgueltig aufgegeben. Bleibt sichtbar — eine stille Warteschlange ist
/// schlimmer als keine.
AUFGEGEBEN
}
/// Was gemeldet wird. Bewusst genau vier Anlaesse (#034).
enum Meldeanlass {
LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN
ABGLEICH_FEHLGESCHLAGEN
}
/// Eine einzelne Meldung in der Warteschlange (#034).
///
/// **In der Datenbank und nicht im Anfrage-Pfad.** Ein haengender Mailserver
/// darf kein Abhaken blockieren: der Dienst reiht ein, der Zeitplan schickt.
///
/// Betreff und Rumpf stehen fertig in der Zeile. Das kostet Platz und ist es
/// wert: eine Meldung sagt, was **damals** war. Baute der Zeitplan den Text erst
/// beim Senden, stuende in der Mail der Stand von jetzt — und in einer
/// aufgegebenen Zustellung von naechster Woche etwas voellig anderes.
model Zustellung {
id String @id @default(cuid())
anlass Meldeanlass
weg Zustellweg
/// Mailadresse oder Webhook-URL. Als Text, damit die Zeile auch dann noch
/// sagt, wohin sie ging, wenn das Konto spaeter deaktiviert wurde.
empfaenger String
betreff String
rumpf String
zustand Zustellzustand @default(OFFEN)
versuche Int @default(0)
/// Vor diesem Zeitpunkt holt der Zeitplan die Zeile nicht ab. Traegt das
/// wachsende Warten zwischen zwei Versuchen.
naechsterVersuchAm DateTime @default(now())
letzterFehler String?
gesendetAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
/// Wer gemeint ist. Leer bei einem Webhook — der gehoert keiner Person.
benutzerId String?
laufId String?
verzeichnisquelleId String?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Cascade)
verzeichnisquelle Verzeichnisquelle? @relation(fields: [verzeichnisquelleId], references: [id], onDelete: Cascade)
/// Der Index, auf dem der Zeitplan arbeitet.
@@index([zustand, naechsterVersuchAm])
@@index([erstelltAm])
@@index([laufId])
@@index([benutzerId])
}
model Kunde { model Kunde {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -134,6 +223,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[]
@@ -160,6 +250,10 @@ model Benutzer {
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten. /// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
sitzungenUngueltigAb DateTime? sitzungenUngueltigAb DateTime?
letzteAnmeldungAm DateTime? letzteAnmeldungAm DateTime?
/// Bekommt diese Person Benachrichtigungen (#034)? Je Empfaenger abschaltbar.
/// Ein Konto ohne Mailadresse gibt es nicht — `email` ist Pflicht —, aber ein
/// Dienstkonto will keine Post.
meldungenAktiv Boolean @default(true)
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
@@ -172,6 +266,8 @@ model Benutzer {
protokolle Protokoll[] protokolle Protokoll[]
schrittergebnisse Schrittergebnis[] schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon") veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[]
zustellungen Zustellung[]
@@index([aktiv]) @@index([aktiv])
@@index([rolle]) @@index([rolle])
@@ -229,6 +325,7 @@ model Verzeichnisquelle {
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[] mitarbeiter Mitarbeiter[]
zustellungen Zustellung[]
@@index([kundeId]) @@index([kundeId])
@@index([aktiv]) @@index([aktiv])
@@ -635,7 +732,12 @@ model Lauf {
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
kommentare Kommentar[] kommentare Kommentar[]
protokolle Protokoll[] protokolle Protokoll[]
anhaenge Anhang[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
zustellungen Zustellung[]
@@unique([id, kundeId])
@@index([kundeId, status]) @@index([kundeId, status])
@@index([geraetId, status]) @@index([geraetId, status])
@@index([mitarbeiterId]) @@index([mitarbeiterId])
@@ -665,11 +767,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
+16 -4
View File
@@ -175,11 +175,23 @@ function feld(text, name) {
); );
} }
for (const [datei, block] of bloecke) { for (const [datei, block] of bloecke) {
if (!/docker compose -f docker-compose\.test\.yml up .*ldap/.test(block)) { /*
melde( * Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und
`${datei}: der Schranken-Block nennt den LDAP-Container nicht. ` + * sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll.
`Ohne ihn ueberspringt die Suite den Verzeichnis-Abgleich, und der Lauf sieht gruen aus.`, */
for (const [dienst, was] of [
["ldap", "den Verzeichnis-Abgleich"],
["mailpit", "den echten Mailversand"],
]) {
const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test(
block,
); );
if (!gefunden) {
melde(
`${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` +
`Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`,
);
}
} }
} }
} }
+32
View File
@@ -1,6 +1,8 @@
import { Rolle } from "@/generated/prisma/enums"; import { Rolle } from "@/generated/prisma/enums";
import { Bell, BellOff } from "lucide-react";
import { BenutzerAnlegen } from "@/components/benutzer-anlegen"; import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer"; import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
import { meldungenSchalterAktion } from "@/server/actions/benachrichtigungen";
import { lokalerLoginAktiv } from "@/server/auth/benutzer"; import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { ROLLEN_TEXT } from "@/lib/rollen-text"; import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -59,6 +61,36 @@ function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolea
</button> </button>
</form> </form>
{/*
* Benachrichtigungen auch **fuer sich selbst** umschaltbar, anders als
* „aktiv“: sich selbst zu deaktivieren waere ein Eigentor, seine
* eigene Post abzustellen ist der haeufigste Wunsch ueberhaupt. Eine
* Profilseite, auf der das jeder selbst koennte, kommt mit der
* Selbstauskunft (#035).
*/}
<form action={meldungenSchalterAktion}>
<input type="hidden" name="benutzerId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.meldungenAktiv ? "aus" : "an"} />
<button
type="submit"
aria-label={`Benachrichtigungen für ${zeile.name} ${
zeile.meldungenAktiv ? "abschalten" : "einschalten"
}`}
className={`flex min-h-11 items-center gap-1.5 rounded-pille px-3 text-sm md:min-h-9 ${
zeile.meldungenAktiv
? "bg-flaeche-hoch text-nebentext hover:text-text"
: "bg-flaeche-hoch text-nebentext line-through hover:text-text"
}`}
>
{zeile.meldungenAktiv ? (
<Bell className="size-4" strokeWidth={1.75} aria-hidden="true" />
) : (
<BellOff className="size-4" strokeWidth={1.75} aria-hidden="true" />
)}
Mail
</button>
</form>
{selbst ? ( {selbst ? (
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span> <span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
) : ( ) : (
+1
View File
@@ -15,6 +15,7 @@ const REITER = [
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" }, { pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" }, { pfad: "/admin/protokoll", beschriftung: "Protokoll" },
{ pfad: "/admin/zustellungen", beschriftung: "Zustellungen" },
/* /*
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD, * Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu * aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
+21 -2
View File
@@ -3,6 +3,8 @@ import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel"; import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen"; import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
import { meldungseinstellungenHolen } from "@/server/services/benachrichtigungen";
import { BenachrichtigungenFormular } from "@/components/benachrichtigungen-formular";
const MODUS_NAME: Record<Betriebsmodus, string> = { const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern", INTERN: "Intern",
@@ -17,8 +19,8 @@ const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
}; };
export default async function EinstellungenSeite() { export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin"); const { benutzer } = await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen(); const [einstellungen, meldungen] = await Promise.all([einstellungenHolen(), meldungseinstellungenHolen()]);
const gegenteil = const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN; einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
@@ -68,6 +70,23 @@ export default async function EinstellungenSeite() {
/> />
</section> </section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<BenachrichtigungenFormular
werte={{
smtpHost: meldungen.smtp.host ?? "",
smtpPort: meldungen.smtp.port === null ? "" : String(meldungen.smtp.port),
smtpTls: meldungen.smtp.tls,
smtpBenutzer: meldungen.smtp.benutzer ?? "",
smtpAbsender: meldungen.smtp.absender ?? "",
smtpPasswortGesetzt: meldungen.smtp.passwortGesetzt,
webhookUrl: meldungen.webhook.url ?? "",
webhookGeheimnisGesetzt: meldungen.webhook.geheimnisGesetzt,
appUrlFehlt: meldungen.appUrl === null,
eigeneMail: benutzer.email,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5"> <section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2> <h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext"> <p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
+195
View File
@@ -0,0 +1,195 @@
import Link from "next/link";
import { AlertTriangle, Check, Clock, RotateCcw } from "lucide-react";
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zustellungenHolen, zustellungenZaehlen } from "@/server/queries/zustellungen";
/**
* Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder Lauf funktioniert
* weiter, nur erfaehrt niemand davon.
*
* Gezeigt wird der **Betreff**, nicht der Rumpf: fuer „laeuft der Versand?“
* braucht es ihn nicht, und er traegt Lauftitel und Geraetenamen.
*/
const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
};
const ZUSTAND_TEXT: Record<Zustellzustand, string> = {
OFFEN: "offen",
GESENDET: "gesendet",
FEHLGESCHLAGEN: "wird wiederholt",
AUFGEGEBEN: "aufgegeben",
};
const ZUSTAND_FARBE: Record<Zustellzustand, string> = {
OFFEN: "text-nebentext",
GESENDET: "text-erledigt",
FEHLGESCHLAGEN: "text-faellig",
AUFGEGEBEN: "text-fehler",
};
function zeitpunkt(wert: Date): string {
return wert.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
}
export default async function ZustellungenSeite({
searchParams,
}: {
searchParams: Promise<{ zustand?: string }>;
}) {
// Jede Seite prüft selbst – die Hülle ist das zweite Netz (CLAUDE.md).
await sitzungErforderlich("verwaltung", "/admin/zustellungen");
const { zustand: roherZustand } = await searchParams;
const gefiltert =
roherZustand && roherZustand in Zustellzustand ? (roherZustand as Zustellzustand) : undefined;
const [zeilen, zaehler] = await Promise.all([
zustellungenHolen(gefiltert ? { zustand: gefiltert } : {}),
zustellungenZaehlen(),
]);
const aufgegeben = zaehler[Zustellzustand.AUFGEGEBEN];
return (
<>
<h1 className="text-2xl font-bold">Zustellungen</h1>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Was Laufzettel per Mail und Webhook hinausgeschickt hat. Eine fehlgeschlagene Zustellung wird
wiederholt — nach 1, 5, 30 und 120 Minuten — und bleibt nach dem fünften Fehlschlag als{" "}
<strong>aufgegeben</strong> stehen, damit es jemand sieht.
</p>
{aufgegeben > 0 ? (
<p
role="alert"
className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-fehler/40 bg-flaeche p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
<span>
<strong>{aufgegeben}</strong> {aufgegeben === 1 ? "Zustellung wurde" : "Zustellungen wurden"}{" "}
aufgegeben. Der Grund steht jeweils unter der Zeile; meist stimmt der SMTP-Zugang nicht mehr.
</span>
</p>
) : null}
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Nach Zustand filtern">
<Link
href="/admin/zustellungen"
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === undefined
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
Alle
</Link>
{Object.values(Zustellzustand).map((wert) => (
<Link
key={wert}
href={`/admin/zustellungen?zustand=${wert}`}
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === wert
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
{ZUSTAND_TEXT[wert]}
<span className="text-xs">{zaehler[wert]}</span>
</Link>
))}
</nav>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
{gefiltert === undefined
? "Noch nichts verschickt. Sobald ein Lauf jemandem zugewiesen, überfällig oder " +
"abgeschlossen wird, steht es hier."
: "In diesem Zustand steht gerade nichts."}
</p>
) : (
/* Am Handy nie waagerecht scrollen: dort Karten statt einer Tabelle. */
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className={`flex items-center gap-1.5 font-semibold ${ZUSTAND_FARBE[zeile.zustand]}`}>
{zeile.zustand === Zustellzustand.GESENDET ? (
<Check className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.AUFGEGEBEN ? (
<AlertTriangle className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<RotateCcw className="size-4" strokeWidth={2} aria-hidden="true" />
) : (
<Clock className="size-4" strokeWidth={2} aria-hidden="true" />
)}
{ZUSTAND_TEXT[zeile.zustand]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{ANLASS_TEXT[zeile.anlass]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{zeile.weg === Zustellweg.MAIL ? "Mail" : "Webhook"}
</span>
{zeile.versuche > 1 ? (
<span className="text-xs text-nebentext">{zeile.versuche} Versuche</span>
) : null}
</div>
<p className="mt-2 break-words">{zeile.betreff}</p>
<p className="mt-1 break-all text-xs text-nebentext">{zeile.empfaenger}</p>
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
<div>
<dt className="inline">Eingereiht: </dt>
<dd className="inline">{zeitpunkt(zeile.erstelltAm)}</dd>
</div>
{zeile.gesendetAm ? (
<div>
<dt className="inline">Gesendet: </dt>
<dd className="inline">{zeitpunkt(zeile.gesendetAm)}</dd>
</div>
) : null}
{zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<div>
<dt className="inline">Nächster Versuch: </dt>
<dd className="inline">{zeitpunkt(zeile.naechsterVersuchAm)}</dd>
</div>
) : null}
{zeile.laufId ? (
<div>
<Link href={`/laeufe/${zeile.laufId}`} className="underline hover:text-text">
Zum Lauf
</Link>
</div>
) : null}
</dl>
{zeile.letzterFehler ? (
<p className="mono mt-3 rounded-karte border border-linie bg-grund px-3 py-2 text-xs break-words text-fehler">
{zeile.letzterFehler}
</p>
) : null}
</li>
))}
</ul>
)}
</>
);
}
+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",
},
});
} }
@@ -0,0 +1,236 @@
"use client";
import { useActionState } from "react";
import { Mail, Send, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import {
meldungenSpeichernAktion,
testmailAktion,
type AktionsErgebnis,
} from "@/server/actions/benachrichtigungen";
/**
* SMTP und Webhook einrichten (#034).
*
* Zwei Dinge sind hier bewusst:
*
* 1. **Ein gesetztes Passwort wird nie angezeigt**, nur dass es eines gibt. Das
* Feld bleibt leer, und leer heisst „unverändert lassen“ — sonst loeschte
* jedes Speichern der uebrigen Felder das Passwort mit.
* 2. **Die Testmail ist ein eigenes Formular** und geht direkt hinaus, nicht
* ueber die Warteschlange. Ein Knopf, der „eingereiht“ meldet und den Fehler
* eine Minute spaeter woanders zeigt, prueft nichts.
*/
export type MeldungsWerte = {
readonly smtpHost: string;
readonly smtpPort: string;
readonly smtpTls: boolean;
readonly smtpBenutzer: string;
readonly smtpAbsender: string;
readonly smtpPasswortGesetzt: boolean;
readonly webhookUrl: string;
readonly webhookGeheimnisGesetzt: boolean;
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
readonly appUrlFehlt: boolean;
readonly eigeneMail: string;
};
function Rueckmeldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
export function BenachrichtigungenFormular({ werte }: { werte: MeldungsWerte }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(meldungenSpeichernAktion, {});
const [testErgebnis, testAktion, testLaeuft] = useActionState<AktionsErgebnis, FormData>(
testmailAktion,
{},
);
return (
<div className="flex flex-col gap-8">
<section>
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Mail className="size-[18px] text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Benachrichtigungen
</h2>
</div>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Vier Anlässe gehen hinaus: ein Lauf wird jemandem <strong>zugewiesen</strong>, ein Lauf ist{" "}
<strong>überfällig</strong>, ein Lauf wird <strong>abgeschlossen</strong>, und ein{" "}
<strong>Verzeichnisabgleich schlägt fehl</strong>. Die Nachricht enthält Titel, Status und einen
Link — <strong>keine Schrittinhalte</strong>, und damit auch kein erzeugtes Passwort.
</p>
{werte.appUrlFehlt ? (
<p role="alert" className="mb-4 max-w-prose text-sm text-faellig">
<code className="mono">APP_URL</code> ist nicht gesetzt. Die Meldungen gehen trotzdem hinaus, aber
der Link darin führt nirgendwohin.
</p>
) : null}
</section>
<form action={aktion} className="flex flex-col gap-8">
<section>
<h3 className="text-sm font-semibold">SMTP</h3>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Ohne Host geht keine Mail hinaus — die Webhook-Zustellung unten funktioniert davon unabhängig.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Host</span>
<input
name="smtpHost"
defaultValue={werte.smtpHost}
placeholder="mail.firma.de"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Port</span>
<input
name="smtpPort"
defaultValue={werte.smtpPort}
inputMode="numeric"
placeholder="587"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Benutzer</span>
<input
name="smtpBenutzer"
defaultValue={werte.smtpBenutzer}
autoComplete="off"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Passwort
{werte.smtpPasswortGesetzt ? (
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
) : null}
</span>
<input
name="smtpPasswort"
type="password"
autoComplete="new-password"
placeholder={werte.smtpPasswortGesetzt ? "unverändert" : ""}
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Absender</span>
<input
name="smtpAbsender"
defaultValue={werte.smtpAbsender}
placeholder="laufzettel@firma.de"
className={FELD}
/>
</label>
<label className="flex min-h-11 items-center gap-3 self-end text-sm md:min-h-10">
<input
type="checkbox"
name="smtpTls"
value="an"
defaultChecked={werte.smtpTls}
className="size-4 accent-akzent"
/>
<span>
TLS verlangen
<span className="block text-xs text-nebentext">
Port 465 verschlüsselt von Anfang an, 587 per StartTLS.
</span>
</span>
</label>
</div>
</section>
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Webhook className="size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Webhook
</h3>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Dieselben vier Anlässe als <code className="mono">POST</code> mit JSON. Mit Geheimnis trägt jede
Anfrage eine HMAC-SHA256-Signatur im Kopf <code className="mono">X-Laufzettel-Signatur</code>; wie
sie nachzurechnen ist, steht in <code className="mono">docs/betrieb/benachrichtigungen.md</code>.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Adresse</span>
<input
name="webhookUrl"
defaultValue={werte.webhookUrl}
placeholder="https://hook.firma.de/laufzettel"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Geheimnis
{werte.webhookGeheimnisGesetzt ? (
<span className="ml-2 text-xs">(gesetzt — leer lassen, um es zu behalten)</span>
) : null}
</span>
<input
name="webhookGeheimnis"
type="password"
autoComplete="new-password"
placeholder={werte.webhookGeheimnisGesetzt ? "unverändert" : "ohne Signatur"}
className={FELD}
/>
</label>
</div>
</section>
<div className="flex flex-wrap items-center gap-4">
{/*
* Eigene Beschriftung, nicht bloss „Speichern“: auf dieser Seite gibt
* es zwei Formulare, und zwei gleich benannte Knoepfe sind fuer einen
* Menschen genauso verwirrend wie fuer einen Test.
*/}
<Button type="submit" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Benachrichtigungen speichern"}
</Button>
<Rueckmeldung ergebnis={ergebnis} />
</div>
</form>
<form action={testAktion} className="flex flex-col gap-3 border-t border-linie pt-6">
<h3 className="text-sm font-semibold">Testmail</h3>
<p className="max-w-prose text-sm text-nebentext">
Geht <strong>direkt</strong> hinaus, nicht über die Warteschlange — ein Fehler erscheint deshalb
sofort und im Klartext. Gespeichert sein muss der Zugang vorher.
</p>
<div className="flex flex-wrap items-end gap-3">
<label className="flex max-w-md flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">An</span>
<input name="an" type="email" defaultValue={werte.eigeneMail} className={FELD} />
</label>
<Button type="submit" variant="secondary" disabled={testLaeuft}>
<Send className="size-4" strokeWidth={2} aria-hidden="true" />
{testLaeuft ? "Wird geschickt …" : "Testmail schicken"}
</Button>
</div>
<Rueckmeldung ergebnis={testErgebnis} />
</form>
</div>
);
}
+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>
);
}
+9 -1
View File
@@ -1,7 +1,8 @@
/** /**
* Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016). * Der eine Einstiegspunkt, den Next je Server-Prozess genau einmal ruft (#016).
* *
* Hier startet der Zeitplan des Verzeichnis-Abgleichs. Zwei Wachen davor: * Hier starten die beiden Zeitplaene: der Verzeichnis-Abgleich (#016) und die
* Benachrichtigungen (#034). Zwei Wachen davor:
* *
* - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit, * - **Nur in der Node-Laufzeit.** `register` laeuft auch fuer die Edge-Laufzeit,
* und dort gibt es weder Datenbank noch `node-cron`. * und dort gibt es weder Datenbank noch `node-cron`.
@@ -15,4 +16,11 @@ export async function register(): Promise<void> {
const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan"); const { zeitplanStarten } = await import("@/server/zeitplan/abgleich-plan");
zeitplanStarten(); zeitplanStarten();
/*
* Ein eigener Zeitplan und nicht derselbe: der Mailversand darf nicht warten,
* weil der Abgleich gerade ein grosses Verzeichnis liest.
*/
const { meldungsplanStarten } = await import("@/server/zeitplan/meldungs-plan");
meldungsplanStarten();
} }
+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);
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Die Texte der Benachrichtigungen (#034).
*
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
* Meilensteins sitzt und einzeln pruefbar sein muss:
*
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
* Server, der nicht im Haus steht.
*
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
* aendern, und dann steht er vor diesem Kommentar.
*/
import { Meldeanlass } from "@/generated/prisma/enums";
/**
* Die drei Anlaesse, die zu einem Lauf gehoeren.
*
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
* waere derselbe Fehler erst im Betrieb aufgefallen.
*/
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
export type LaufKopf = {
readonly titel: string;
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
readonly geraet?: string | null;
readonly person?: string | null;
readonly faelligAm?: Date | null;
readonly vorlage?: string | null;
/** Wer zugewiesen oder abgeschlossen hat. */
readonly handelnder?: string | null;
};
export type Meldung = { readonly betreff: string; readonly rumpf: string };
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
function datum(wert: Date): string {
return wert.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
}
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
function kopfzeilen(kopf: LaufKopf): string[] {
const zeilen: string[] = [];
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
return zeilen;
}
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
}
/**
* Die Meldung zu einem Lauf.
*
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
* relativen Pfad ist eine Mail ohne Link.
*/
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
const zeilen = kopfzeilen(kopf);
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
switch (anlass) {
case Meldeanlass.LAUF_ZUGEWIESEN:
return {
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
: "Dir wurde ein Laufzettel zugewiesen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_UEBERFAELLIG:
return {
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.faelligAm
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
: "Dieser Laufzettel ist überfällig und noch offen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_ABGESCHLOSSEN:
return {
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
: "Dieser Laufzettel wurde abgeschlossen.",
daten,
link,
]),
};
default: {
/*
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis.
*/
const unbekannt: never = anlass;
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
}
}
}
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
return {
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
rumpf: zusammensetzen([
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
`Grund: ${grund}`,
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
link,
]),
};
}
/**
* Der Rumpf eines Webhooks.
*
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
* ohne Auspacken filtern laesst.
*/
export function webhookRumpf(
anlass: Meldeanlass,
betreff: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
gesendetAm: Date,
): string {
return JSON.stringify({
anlass,
betreff,
link,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
zeitpunkt: gesendetAm.toISOString(),
});
}
+82
View File
@@ -0,0 +1,82 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Die Signatur eines Webhooks (#034).
*
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
* zeigen.
*
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
*
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
* wird deshalb `<zeitstempel>.<rumpf>`.
*/
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
export const TOLERANZ_SEKUNDEN = 300;
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
}
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
}
export type SignaturPruefung =
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
/**
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
*
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
* niemand nachgerechnet hat.
*/
export function signaturPruefen(
geheimnis: string,
rumpf: string,
kopf: string,
jetztSekunden: number,
toleranzSekunden = TOLERANZ_SEKUNDEN,
): SignaturPruefung {
const teile = new Map<string, string>();
for (const stueck of kopf.split(",")) {
const trenner = stueck.indexOf("=");
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
}
const roherStempel = teile.get("t");
const mitgeschickt = teile.get("v1");
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
const zeitstempel = Number.parseInt(roherStempel, 10);
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
/*
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
*/
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
return { gueltig: false, grund: "abgelaufen" };
}
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
/*
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
*/
const a = Buffer.from(erwartet, "utf8");
const b = Buffer.from(mitgeschickt, "utf8");
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
}
+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.");
}
}
+145
View File
@@ -0,0 +1,145 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MeldungsFehler,
meldungseinstellungenSpeichern,
testmailSenden,
} from "@/server/services/benachrichtigungen";
import { prisma } from "@/lib/db";
/** Server Actions der Benachrichtigungen (#034). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
/**
* Ein leeres Feld heisst „loeschen“, ein fehlendes „unverändert lassen“.
*
* Beim Passwort ist das der Unterschied, auf den es ankommt: das Formular zeigt
* es nie an, also kommt es leer zurueck. Wuerde leer als „loeschen“ gelesen,
* loeschte jedes Speichern der uebrigen Felder das Passwort mit.
*/
const MeldungsSchema = z.object({
smtpHost: z.string().trim().optional(),
smtpPort: z.string().trim().optional(),
smtpTls: z.string().optional(),
smtpBenutzer: z.string().trim().optional(),
smtpPasswort: z.string().optional(),
smtpAbsender: z.string().trim().optional(),
webhookUrl: z.string().trim().optional(),
webhookGeheimnis: z.string().optional(),
});
export async function meldungenSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = MeldungsSchema.safeParse({
smtpHost: formular.get("smtpHost") ?? undefined,
smtpPort: formular.get("smtpPort") ?? undefined,
smtpTls: formular.get("smtpTls") ?? undefined,
smtpBenutzer: formular.get("smtpBenutzer") ?? undefined,
smtpPasswort: formular.get("smtpPasswort") ?? undefined,
smtpAbsender: formular.get("smtpAbsender") ?? undefined,
webhookUrl: formular.get("webhookUrl") ?? undefined,
webhookGeheimnis: formular.get("webhookGeheimnis") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const roherPort = eingabe.data.smtpPort ?? "";
let port: number | null = null;
if (roherPort !== "") {
const gelesen = Number.parseInt(roherPort, 10);
if (!Number.isFinite(gelesen)) return { fehler: "Der SMTP-Port muss eine Zahl sein." };
port = gelesen;
}
try {
await meldungseinstellungenSpeichern(
{
smtpHost: eingabe.data.smtpHost ?? null,
smtpPort: port,
smtpTls: eingabe.data.smtpTls === "an",
smtpBenutzer: eingabe.data.smtpBenutzer ?? null,
// Leer = unverändert. Das Formular zeigt ein gesetztes Passwort nie an.
...(eingabe.data.smtpPasswort ? { smtpPasswort: eingabe.data.smtpPasswort } : {}),
smtpAbsender: eingabe.data.smtpAbsender ?? null,
webhookUrl: eingabe.data.webhookUrl ?? null,
...(eingabe.data.webhookGeheimnis ? { webhookGeheimnis: eingabe.data.webhookGeheimnis } : {}),
},
benutzer.id,
);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
revalidatePath("/admin");
return { erfolg: "Gespeichert." };
}
export async function testmailAktion(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const an = z.string().trim().min(1).safeParse(formular.get("an"));
// Ohne Angabe an den Administrator selbst – das ist der häufigste Fall.
const ziel = an.success ? an.data : benutzer.email;
try {
await testmailSenden(ziel);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
return { erfolg: `Testmail an ${ziel} ist hinausgegangen.` };
}
/**
* Benachrichtigungen je Person an- oder abschalten.
*
* Recht `verwaltung`, wie alles an einem Konto — es gibt kein eigenes Recht fuer
* Benutzer, die Rechtetabelle kennt `verwaltung` fuer ADMIN. **Fuer das eigene
* Konto fehlt damit noch die Selbstbedienung**: eine Profilseite gibt es bis
* Phase 2 nicht, sie kommt mit der Selbstauskunft (#035). Bis dahin stellt ein
* Administrator es um, und das steht so auch in der Anleitung.
*/
export async function meldungenSchalterAktion(formular: FormData): Promise<void> {
const { benutzer } = await rechtErforderlich("verwaltung");
/*
* Einstellig und ohne Rueckgabe, wie `aktivSetzenAktion` daneben: der Schalter
* sitzt in einem gewoehnlichen Formular in der Liste, nicht in einem
* `useActionState`. Eine zweistellige Action passt dort nicht hinein — und
* eine zweite Form derselben Sache waere Lärm.
*/
const eingabe = z
.object({ benutzerId: z.string().min(1), aktiv: z.enum(["an", "aus"]) })
.parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") });
const ziel = await prisma.benutzer.findUnique({
where: { id: eingabe.benutzerId },
select: { id: true, name: true, meldungenAktiv: true },
});
if (!ziel) return;
const neu = eingabe.aktiv === "an";
if (ziel.meldungenAktiv !== neu) {
await prisma.benutzer.update({ where: { id: ziel.id }, data: { meldungenAktiv: neu } });
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: ziel.id,
aktion: neu ? "Benachrichtigungen eingeschaltet" : "Benachrichtigungen abgeschaltet",
benutzerId: benutzer.id,
vorher: { meldungenAktiv: ziel.meldungenAktiv },
nachher: { meldungenAktiv: neu },
},
});
}
revalidatePath("/admin/benutzer");
}
+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);
}
+4
View File
@@ -9,6 +9,8 @@ export type BenutzerZeile = {
readonly name: string; readonly name: string;
readonly rolle: Rolle; readonly rolle: Rolle;
readonly aktiv: boolean; readonly aktiv: boolean;
/** Bekommt diese Person Benachrichtigungen (#034)? */
readonly meldungenAktiv: boolean;
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */ /** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
readonly mitPasswort: boolean; readonly mitPasswort: boolean;
readonly ueberOidc: boolean; readonly ueberOidc: boolean;
@@ -24,6 +26,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: true, name: true,
rolle: true, rolle: true,
aktiv: true, aktiv: true,
meldungenAktiv: true,
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft, // Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
// ob ueberhaupt einer da ist. // ob ueberhaupt einer da ist.
passwortHash: true, passwortHash: true,
@@ -38,6 +41,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: zeile.name, name: zeile.name,
rolle: zeile.rolle, rolle: zeile.rolle,
aktiv: zeile.aktiv, aktiv: zeile.aktiv,
meldungenAktiv: zeile.meldungenAktiv,
mitPasswort: zeile.passwortHash !== null, mitPasswort: zeile.passwortHash !== null,
ueberOidc: zeile.oidcSub !== null, ueberOidc: zeile.oidcSub !== null,
letzteAnmeldungAm: zeile.letzteAnmeldungAm, letzteAnmeldungAm: zeile.letzteAnmeldungAm,
+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),
+80
View File
@@ -0,0 +1,80 @@
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Die letzten Zustellungen – fuer Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder einzelne Lauf
* funktioniert weiter, nur erfaehrt niemand davon.
*
* Keine Mandantensicht: das Recht `verwaltung` hat nur, wer alles sehen darf,
* und eine Zustellung gehoert zum Betrieb der Anwendung, nicht zu einem Kunden.
* Der **Rumpf** wird trotzdem nicht ausgeliefert — er traegt Lauftitel und
* Geraetenamen, und fuer „laeuft der Versand?“ braucht es ihn nicht.
*/
export type ZustellZeile = {
readonly id: string;
readonly anlass: Meldeanlass;
readonly weg: Zustellweg;
readonly empfaenger: string;
readonly betreff: string;
readonly zustand: Zustellzustand;
readonly versuche: number;
readonly letzterFehler: string | null;
readonly naechsterVersuchAm: Date;
readonly gesendetAm: Date | null;
readonly erstelltAm: Date;
readonly laufId: string | null;
};
export const LISTE_HOECHSTENS = 200;
export type ZustellFilter = {
readonly zustand?: Zustellzustand;
};
export async function zustellungenHolen(filter: ZustellFilter = {}): Promise<readonly ZustellZeile[]> {
const zeilen = await prisma.zustellung.findMany({
where: filter.zustand ? { zustand: filter.zustand } : {},
orderBy: { erstelltAm: "desc" },
take: LISTE_HOECHSTENS,
select: {
id: true,
anlass: true,
weg: true,
empfaenger: true,
betreff: true,
zustand: true,
versuche: true,
letzterFehler: true,
naechsterVersuchAm: true,
gesendetAm: true,
erstelltAm: true,
laufId: true,
},
});
return zeilen;
}
export type ZustellZaehler = Readonly<Record<Zustellzustand, number>>;
/**
* Wie viele in welchem Zustand.
*
* Die Zahl der **aufgegebenen** ist die eine, die jemand sehen muss: alles
* andere regelt sich von selbst.
*/
export async function zustellungenZaehlen(): Promise<ZustellZaehler> {
const gruppen = await prisma.zustellung.groupBy({ by: ["zustand"], _count: { _all: true } });
const leer: Record<Zustellzustand, number> = {
[Zustellzustand.OFFEN]: 0,
[Zustellzustand.GESENDET]: 0,
[Zustellzustand.FEHLGESCHLAGEN]: 0,
[Zustellzustand.AUFGEGEBEN]: 0,
};
for (const gruppe of gruppen) leer[gruppe.zustand] = gruppe._count._all;
return leer;
}
+21 -54
View File
@@ -1,5 +1,3 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client"; import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -10,6 +8,8 @@ import { eintraegeStroemen } from "@/server/ldap/leser";
import { fehlerMeldung } from "@/server/ldap/verbindung"; import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung"; import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis"; import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
/** /**
* Der Abgleich (#016). * Der Abgleich (#016).
@@ -81,55 +81,6 @@ function sperrSchluessel(quelleId: string): string {
return `abgleich:${quelleId}`; return `abgleich:${quelleId}`;
} }
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitAbgleichSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
/** /**
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan. * Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
* *
@@ -218,9 +169,7 @@ export async function abgleichAusfuehren(
} }
const quelle = await quelleZeileHolen(quelleId); const quelle = await quelleZeileHolen(quelleId);
const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () => const ergebnis = await mitSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () => lauf(quelle, benutzer));
lauf(quelle, benutzer),
);
if (ergebnis === undefined) { if (ergebnis === undefined) {
throw new AbgleichLaeuftBereitsFehler( throw new AbgleichLaeuftBereitsFehler(
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`, `Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
@@ -599,4 +548,22 @@ async function abschlussSchreiben(
}, },
}); });
}); });
/*
* Ein fehlgeschlagener Abgleich ist der eine Fall, von dem niemand erfaehrt,
* solange er nicht nachsieht (#034). Eingereiht wird **nach** der Transaktion
* und mit abgefangenem Fehler: der Abgleich selbst ist fertig, und eine
* Benachrichtigung, die nicht eingereiht werden kann, darf sein Ergebnis nicht
* verwerfen.
*/
if (ergebnis.status !== "ERFOLG") {
try {
await abgleichMeldungEinreihen(quelleId, ergebnis.fehler ?? "ohne Angabe");
} catch (fehler) {
console.error(
`Benachrichtigung zum Abgleich der Quelle ${quelleId} konnte nicht eingereiht werden:`,
fehler instanceof Error ? fehler.message : fehler,
);
}
}
} }
+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 };
+642
View File
@@ -0,0 +1,642 @@
import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
import { sichtbareKundenIds } from "@/server/mandant";
import { mitSperre } from "@/server/services/sperre";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Vier Anlaesse, nicht mehr: **zugewiesen**, **ueberfaellig**, **abgeschlossen**,
* **Abgleich fehlgeschlagen**. Ein allgemeiner Benachrichtigungsbaukasten ist
* eine Phase fuer sich; diese vier decken ab, was im Betrieb wirklich fehlt.
*
* Drei Entscheidungen tragen das Ganze:
*
* 1. **Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein haengender
* Mailserver darf kein Abhaken blockieren. Der Dienst schreibt eine Zeile in
* `Zustellung` — das ist eine Einfuegung in derselben Datenbank, die der
* Vorgang ohnehin anfasst —, und der Zeitplan schickt sie.
* 2. **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was
* **damals** war. Baute der Zeitplan den Text erst beim Senden, stuende in
* der Mail der Stand von jetzt.
* 3. **In keiner Meldung steht ein Schrittinhalt.** Darueber wacht
* `src/lib/meldungen.ts`; hier kommt nur hinzu, dass auch die Empfaengerwahl
* die Mandantengrenze achtet.
*/
export class MeldungsFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "MeldungsFehler";
}
}
/**
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
*
* Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
* Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
* Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
* wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
*/
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
/** Wie viele Zeilen ein Durchgang hoechstens anfasst. */
const BUENDEL = 50;
/**
* Wie lange die Sperre der Warteschlange gilt.
*
* Kurz, weil ein Durchgang kurz ist — und weil ein Absturz mitten im Senden die
* Warteschlange sonst fuer Stunden stillstellte.
*/
const SPERRE_MINUTEN = 10;
export const WARTESCHLANGE_SCHLUESSEL = "zustellung:warteschlange";
/* ------------------------------------------------------------------ */
/* Einstellungen */
/* ------------------------------------------------------------------ */
export type SmtpAngaben = {
readonly host: string | null;
readonly port: number | null;
readonly tls: boolean;
readonly benutzer: string | null;
readonly absender: string | null;
/**
* Nur, **ob** eines gesetzt ist — nie der Wert.
*
* Dieselbe Form wie beim LDAP-Bind-Passwort: ein Feld, das es nicht in eine
* Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
readonly passwortGesetzt: boolean;
};
export type WebhookAngaben = {
readonly url: string | null;
readonly geheimnisGesetzt: boolean;
};
export type MeldungsEinstellungen = {
readonly smtp: SmtpAngaben;
readonly webhook: WebhookAngaben;
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
readonly appUrl: string | null;
};
export async function meldungseinstellungenHolen(): Promise<MeldungsEinstellungen> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
return {
smtp: {
host: zeile?.smtpHost ?? null,
port: zeile?.smtpPort ?? null,
tls: zeile?.smtpTls ?? true,
benutzer: zeile?.smtpBenutzer ?? null,
absender: zeile?.smtpAbsender ?? null,
passwortGesetzt: Boolean(zeile?.smtpPasswortVerschluesselt),
},
webhook: {
url: zeile?.webhookUrl ?? null,
geheimnisGesetzt: Boolean(zeile?.webhookGeheimnisVerschluesselt),
},
appUrl: process.env["APP_URL"]?.trim() || null,
};
}
export type MeldungsAenderung = {
readonly smtpHost?: string | null;
readonly smtpPort?: number | null;
readonly smtpTls?: boolean;
readonly smtpBenutzer?: string | null;
/** `undefined` laesst es stehen, `null` loescht es, ein Text setzt es neu. */
readonly smtpPasswort?: string | null;
readonly smtpAbsender?: string | null;
readonly webhookUrl?: string | null;
readonly webhookGeheimnis?: string | null;
};
function geputzt(wert: string | null | undefined): string | null | undefined {
if (wert === undefined) return undefined;
if (wert === null) return null;
return wert.trim() || null;
}
export async function meldungseinstellungenSpeichern(
aenderung: MeldungsAenderung,
durchBenutzerId: string,
): Promise<void> {
const host = geputzt(aenderung.smtpHost);
if (host && aenderung.smtpPort === null) {
throw new MeldungsFehler("Zu einem SMTP-Host gehört ein Port.");
}
if (aenderung.smtpPort !== undefined && aenderung.smtpPort !== null) {
if (!Number.isInteger(aenderung.smtpPort) || aenderung.smtpPort < 1 || aenderung.smtpPort > 65535) {
throw new MeldungsFehler("Der SMTP-Port muss zwischen 1 und 65535 liegen.");
}
}
const url = geputzt(aenderung.webhookUrl);
if (url) {
let geprueft: URL;
try {
geprueft = new URL(url);
} catch {
throw new MeldungsFehler("Die Webhook-Adresse ist keine gültige URL.");
}
if (geprueft.protocol !== "https:" && geprueft.protocol !== "http:") {
throw new MeldungsFehler("Die Webhook-Adresse muss mit http:// oder https:// beginnen.");
}
}
const vorher = await meldungseinstellungenHolen();
const passwort = geputzt(aenderung.smtpPasswort);
const geheimnis = geputzt(aenderung.webhookGeheimnis);
const daten = {
...(host !== undefined ? { smtpHost: host } : {}),
...(aenderung.smtpPort !== undefined ? { smtpPort: aenderung.smtpPort } : {}),
...(aenderung.smtpTls !== undefined ? { smtpTls: aenderung.smtpTls } : {}),
...(geputzt(aenderung.smtpBenutzer) !== undefined
? { smtpBenutzer: geputzt(aenderung.smtpBenutzer) }
: {}),
...(geputzt(aenderung.smtpAbsender) !== undefined
? { smtpAbsender: geputzt(aenderung.smtpAbsender) }
: {}),
...(passwort !== undefined
? { smtpPasswortVerschluesselt: passwort === null ? null : verschluesseln(passwort) }
: {}),
...(url !== undefined ? { webhookUrl: url } : {}),
...(geheimnis !== undefined
? { webhookGeheimnisVerschluesselt: geheimnis === null ? null : verschluesseln(geheimnis) }
: {}),
};
if (Object.keys(daten).length === 0) return;
await prisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", ...daten },
update: daten,
});
/*
* Ins Protokoll gehen **Namen, nicht Werte**. Ein Protokolleintrag mit dem
* SMTP-Passwort waere genau die Stelle, an der die Verschluesselung sinnlos
* wird — und das Protokoll ist ansehbar (#023).
*/
await prisma.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Benachrichtigungen geändert",
benutzerId: durchBenutzerId,
vorher: {
smtpHost: vorher.smtp.host,
smtpPort: vorher.smtp.port,
smtpTls: vorher.smtp.tls,
smtpBenutzer: vorher.smtp.benutzer,
smtpAbsender: vorher.smtp.absender,
smtpPasswortGesetzt: vorher.smtp.passwortGesetzt,
webhookUrl: vorher.webhook.url,
webhookGeheimnisGesetzt: vorher.webhook.geheimnisGesetzt,
},
nachher: { geaenderteFelder: Object.keys(daten) },
},
});
}
/* ------------------------------------------------------------------ */
/* Versand */
/* ------------------------------------------------------------------ */
type SmtpZugang = {
readonly host: string;
readonly port: number;
readonly tls: boolean;
readonly benutzer: string | null;
readonly passwort: string | null;
readonly absender: string;
};
/** Der SMTP-Zugang aus der Datenbank, entschluesselt. `undefined` = nicht eingerichtet. */
async function smtpZugang(): Promise<SmtpZugang | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.smtpHost || !zeile.smtpPort) return undefined;
return {
host: zeile.smtpHost,
port: zeile.smtpPort,
tls: zeile.smtpTls,
benutzer: zeile.smtpBenutzer,
passwort: zeile.smtpPasswortVerschluesselt ? entschluesseln(zeile.smtpPasswortVerschluesselt) : null,
// Ohne eigenen Absender der Benutzername, sonst ein neutraler Name am Host.
absender: zeile.smtpAbsender ?? zeile.smtpBenutzer ?? `laufzettel@${zeile.smtpHost}`,
};
}
async function webhookZugang(): Promise<{ url: string; geheimnis: string | null } | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.webhookUrl) return undefined;
return {
url: zeile.webhookUrl,
geheimnis: zeile.webhookGeheimnisVerschluesselt
? entschluesseln(zeile.webhookGeheimnisVerschluesselt)
: null,
};
}
/**
* Eine Mail verschicken.
*
* `secure` nur auf 465 — das ist der Port fuer implizites TLS. Auf 587 und 25
* wird mit `requireTLS` auf StartTLS bestanden, sofern TLS verlangt ist. Ein
* `secure: true` auf 587 laeuft in einen Zeitablauf, und die Meldung dazu
* („greeting never received“) sagt nichts ueber die Ursache.
*/
async function mailSenden(zugang: SmtpZugang, an: string, betreff: string, text: string): Promise<void> {
const transport = createTransport({
host: zugang.host,
port: zugang.port,
secure: zugang.port === 465,
requireTLS: zugang.tls && zugang.port !== 465,
ignoreTLS: !zugang.tls,
...(zugang.benutzer ? { auth: { user: zugang.benutzer, pass: zugang.passwort ?? "" } } : {}),
// Ein haengender Mailserver darf den Durchgang nicht aufhalten.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
try {
await transport.sendMail({ from: zugang.absender, to: an, subject: betreff, text });
} finally {
transport.close();
}
}
/** Einen Webhook abschicken – mit Signatur und Zeitstempel. */
async function webhookSenden(url: string, geheimnis: string | null, rumpf: string): Promise<void> {
const sekunden = Math.floor(Date.now() / 1000);
const abbruch = AbortSignal.timeout(15_000);
const antwort = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(geheimnis ? { [SIGNATUR_KOPF]: signaturBauen(geheimnis, rumpf, sekunden) } : {}),
},
body: rumpf,
signal: abbruch,
});
if (!antwort.ok) {
throw new Error(`Die Gegenseite antwortete mit ${antwort.status} ${antwort.statusText}`);
}
}
/**
* Eine Testmail, direkt und ohne Warteschlange.
*
* Mit Warteschlange waere der Knopf „Testmail schicken“ wertlos: er wuerde
* „eingereiht“ melden, und ob es klappt, saehe man eine Minute spaeter
* woanders. Hier soll der Fehler **sofort** und im Klartext erscheinen.
*/
export async function testmailSenden(an: string): Promise<void> {
const zugang = await smtpZugang();
if (!zugang) throw new MeldungsFehler("Es ist kein SMTP-Server eingerichtet.");
const ziel = an.trim();
if (!ziel.includes("@")) throw new MeldungsFehler("Das ist keine Mailadresse.");
try {
await mailSenden(
zugang,
ziel,
"Laufzettel: Testmail",
"Wenn diese Nachricht angekommen ist, kann Laufzettel Mail verschicken.\n\n" +
`Server: ${zugang.host}:${zugang.port}${zugang.tls ? " mit TLS" : " ohne TLS"}\n` +
`Absender: ${zugang.absender}`,
);
} catch (fehler) {
throw new MeldungsFehler(
`Die Testmail ging nicht hinaus: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
}
/* ------------------------------------------------------------------ */
/* Einreihen */
/* ------------------------------------------------------------------ */
function appUrl(): string {
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
}
function laufLink(laufId: string): string {
return `${appUrl()}/laeufe/${laufId}`;
}
/**
* Die Empfaenger einer Lauf-Meldung.
*
* Wer zugewiesen ist, und bei einem Abschluss zusaetzlich, wer den Lauf angelegt
* hat. **Nie der Handelnde selbst** — eine Mail darueber, was man gerade eben
* geklickt hat, ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten.
*
* Und: **die Mandantengrenze gilt auch hier.** Ein Benutzer, der den Kunden des
* Laufs nicht sehen darf, bekommt keine Mail mit dessen Lauftitel und Geraet.
* Ohne diese Pruefung waere die Benachrichtigung der eine Weg nach draussen, den
* `sichtbareKundenIds` nicht abdeckt.
*/
async function laufEmpfaenger(
lauf: {
readonly id: string;
readonly kundeId: string;
readonly zugewiesenAnId: string | null;
readonly erstelltVonId: string | null;
},
anlass: LaufAnlass,
handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>();
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId);
}
if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return [];
const kandidaten = await prisma.benutzer.findMany({
where: { id: { in: [...kennungen] }, aktiv: true, meldungenAktiv: true },
select: { id: true, email: true, name: true, rolle: true },
});
const erlaubt: { id: string; email: string; name: string }[] = [];
for (const person of kandidaten) {
const sichtbar = await sichtbareKundenIds({ id: person.id, rolle: person.rolle });
if (sichtbar === undefined || sichtbar.includes(lauf.kundeId)) {
erlaubt.push({ id: person.id, email: person.email, name: person.name });
}
}
return erlaubt;
}
/** Eine Zeile je Empfaenger, und eine fuer den Webhook. */
async function einreihen(
anlass: Meldeanlass,
betreff: string,
rumpf: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
empfaenger: readonly { readonly id: string; readonly email: string }[],
): Promise<number> {
// Ausdruecklich typisiert: aus dem Literal allein leitete TypeScript
// `weg: "MAIL"` und `benutzerId: string` ab, und die Webhook-Zeile darunter
// passte dann nicht mehr hinein.
const zeilen: Prisma.ZustellungCreateManyInput[] = empfaenger.map((person) => ({
anlass,
weg: Zustellweg.MAIL,
empfaenger: person.email,
betreff,
rumpf,
benutzerId: person.id,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
}));
const webhook = await webhookZugang();
if (webhook) {
zeilen.push({
anlass,
weg: Zustellweg.WEBHOOK,
empfaenger: webhook.url,
betreff,
rumpf: webhookRumpf(anlass, betreff, link, bezug, new Date()),
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
benutzerId: null,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
});
}
if (zeilen.length === 0) return 0;
/*
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
*/
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
return ergebnis.count;
}
/** Meldung zu einem Lauf einreihen. Gibt die Zahl der erzeugten Zeilen zurueck. */
export async function laufMeldungEinreihen(
anlass: LaufAnlass,
laufId: string,
handelnderId: string | null = null,
): Promise<number> {
const lauf = await prisma.lauf.findUnique({
where: { id: laufId },
select: {
id: true,
kundeId: true,
titel: true,
faelligAm: true,
zugewiesenAnId: true,
erstelltVonId: true,
mitarbeiterName: true,
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
},
});
if (!lauf) return 0;
const empfaenger = await laufEmpfaenger(lauf, anlass, handelnderId);
const webhook = await webhookZugang();
// Niemand da und kein Webhook: dann auch keine Zeile, die nie ankommt.
if (empfaenger.length === 0 && !webhook) return 0;
const handelnder = handelnderId
? await prisma.benutzer.findUnique({ where: { id: handelnderId }, select: { name: true } })
: null;
const { betreff, rumpf } = laufMeldung(
anlass,
{
titel: lauf.titel,
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
person: lauf.mitarbeiter?.anzeigename ?? lauf.mitarbeiterName,
faelligAm: lauf.faelligAm,
vorlage: lauf.vorlagenVersion.vorlage.name,
handelnder: handelnder?.name ?? null,
},
laufLink(lauf.id),
);
return einreihen(anlass, betreff, rumpf, laufLink(lauf.id), { laufId: lauf.id }, empfaenger);
}
/**
* Meldung zu einem fehlgeschlagenen Abgleich einreihen.
*
* Empfaenger sind **alle aktiven Administratoren**: eine Verzeichnisquelle
* gehoert niemandem persoenlich, und wer sie eingerichtet hat, ist vielleicht
* nicht mehr im Haus.
*/
export async function abgleichMeldungEinreihen(quelleId: string, grund: string): Promise<number> {
const quelle = await prisma.verzeichnisquelle.findUnique({
where: { id: quelleId },
select: { id: true, name: true },
});
if (!quelle) return 0;
const admins = await prisma.benutzer.findMany({
where: { rolle: "ADMIN", aktiv: true, meldungenAktiv: true },
select: { id: true, email: true },
});
const webhook = await webhookZugang();
if (admins.length === 0 && !webhook) return 0;
const link = `${appUrl()}/verwaltung/verzeichnis`;
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
return einreihen(
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
betreff,
rumpf,
link,
{ verzeichnisquelleId: quelle.id },
admins,
);
}
/**
* Ueberfaellige Laeufe einreihen – fuer den Zeitplan.
*
* Faellig **und** nicht vom Tisch. Der eindeutige Index sorgt dafuer, dass das
* je Lauf und Empfaenger genau einmal passiert, auch wenn der Zeitplan jede
* Minute nachsieht.
*/
export async function ueberfaelligeEinreihen(jetzt = new Date()): Promise<number> {
const laeufe = await prisma.lauf.findMany({
where: {
faelligAm: { lt: jetzt },
status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] },
zugewiesenAnId: { not: null },
},
select: { id: true },
});
let erzeugt = 0;
for (const lauf of laeufe) {
erzeugt += await laufMeldungEinreihen(Meldeanlass.LAUF_UEBERFAELLIG, lauf.id);
}
return erzeugt;
}
/* ------------------------------------------------------------------ */
/* Abarbeiten */
/* ------------------------------------------------------------------ */
export type DurchgangErgebnis = {
readonly gesendet: number;
readonly fehlgeschlagen: number;
readonly aufgegeben: number;
};
/**
* Was nach einem Fehlschlag mit der Zeile passiert.
*
* `versucheGesamt` zaehlt **einschliesslich** des gerade gescheiterten: nach dem
* ersten Fehlschlag wird eine Minute gewartet, nach dem zweiten fuenf, dann
* dreissig, dann zwei Stunden — und nach dem fuenften ist Schluss. Vier
* Wartezeiten ergeben also fuenf Versuche.
*
* Eine reine Funktion, damit sich die Wiederholungsregel pruefen laesst, ohne
* einen Mailserver zu brauchen oder zwei Stunden zu warten.
*/
export function naechsterVersuch(
versucheGesamt: number,
jetzt: Date,
): { readonly zustand: Zustellzustand; readonly naechsterVersuchAm: Date } {
const wartezeit = WARTEZEITEN_MINUTEN[versucheGesamt - 1];
if (wartezeit === undefined) {
/*
* Aufgegeben — aber **sichtbar**. Eine stille Warteschlange ist schlimmer
* als keine: niemand wuerde merken, dass seit Wochen keine Mail mehr
* hinausgeht. Der Zeitpunkt bleibt stehen, damit die Liste die Reihenfolge
* behaelt.
*/
return { zustand: Zustellzustand.AUFGEGEBEN, naechsterVersuchAm: jetzt };
}
return {
zustand: Zustellzustand.FEHLGESCHLAGEN,
naechsterVersuchAm: new Date(jetzt.getTime() + wartezeit * 60_000),
};
}
/**
* Einen Durchgang der Warteschlange fahren.
*
* `undefined`, wenn ein anderer Durchgang laeuft — dieselbe Sperre wie beim
* Abgleich, in der Datenbank und damit auch ueber zwei Instanzen hinweg. Ohne
* sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
export async function warteschlangeAbarbeiten(jetzt = new Date()): Promise<DurchgangErgebnis | undefined> {
return mitSperre(WARTESCHLANGE_SCHLUESSEL, SPERRE_MINUTEN, async () => {
const offene = await prisma.zustellung.findMany({
where: {
zustand: { in: [Zustellzustand.OFFEN, Zustellzustand.FEHLGESCHLAGEN] },
naechsterVersuchAm: { lte: jetzt },
},
orderBy: { naechsterVersuchAm: "asc" },
take: BUENDEL,
});
let gesendet = 0;
let fehlgeschlagen = 0;
let aufgegeben = 0;
// Die Zugaenge einmal je Durchgang, nicht je Zeile.
const smtp = await smtpZugang();
const webhook = await webhookZugang();
for (const zeile of offene) {
try {
if (zeile.weg === Zustellweg.MAIL) {
if (!smtp) throw new Error("Es ist kein SMTP-Server eingerichtet.");
await mailSenden(smtp, zeile.empfaenger, zeile.betreff, zeile.rumpf);
} else {
if (!webhook) throw new Error("Es ist kein Webhook eingerichtet.");
await webhookSenden(webhook.url, webhook.geheimnis, zeile.rumpf);
}
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: Zustellzustand.GESENDET,
gesendetAm: new Date(),
versuche: zeile.versuche + 1,
letzterFehler: null,
},
});
gesendet += 1;
} catch (fehler) {
const naechster = naechsterVersuch(zeile.versuche + 1, jetzt);
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: naechster.zustand,
naechsterVersuchAm: naechster.naechsterVersuchAm,
versuche: zeile.versuche + 1,
// Gekappt: eine TLS-Fehlermeldung kann Kilobyte lang sein.
letzterFehler: (fehler instanceof Error ? fehler.message : "unbekannter Fehler").slice(0, 500),
},
});
if (naechster.zustand === Zustellzustand.AUFGEGEBEN) aufgegeben += 1;
else fehlgeschlagen += 1;
}
}
return { gesendet, fehlgeschlagen, aufgegeben };
});
}
+58 -8
View File
@@ -1,7 +1,9 @@
import { Prisma, type Lauf } from "@/generated/prisma/client"; import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
import type { LaufAnlass } from "@/lib/meldungen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { import {
MandantFehler, MandantFehler,
@@ -455,6 +457,30 @@ function variablenPruefen(
* Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht * Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht
* in den eindeutigen Index `(laufId, vorlagenSchrittId)` laufen. * in den eindeutigen Index `(laufId, vorlagenSchrittId)` laufen.
*/ */
/**
* Eine Meldung einreihen, ohne den Vorgang daran haengen zu lassen (#034).
*
* **Nach** der Transaktion und mit abgefangenem Fehler. Zwei Gruende: der Dienst
* liest den Lauf selbst, und innerhalb der Transaktion saehe er die noch nicht
* festgeschriebene Zeile nicht. Und ein Abhaken darf nicht daran scheitern, dass
* eine Benachrichtigung nicht eingereiht werden konnte — gesendet wird sie
* ohnehin erst spaeter vom Zeitplan.
*/
async function meldenOhneBruch(
anlass: LaufAnlass,
laufId: string,
handelnderId: string | null,
): Promise<void> {
try {
await laufMeldungEinreihen(anlass, laufId, handelnderId);
} catch (fehler) {
console.error(
`Benachrichtigung ${anlass} fuer Lauf ${laufId} konnte nicht eingereiht werden:`,
fehler instanceof Error ? fehler.message : fehler,
);
}
}
export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutzer): Promise<Lauf> { export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutzer): Promise<Lauf> {
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer); const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId); const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId);
@@ -494,7 +520,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
.flatMap((abschnitt) => abschnitt.schrittIds), .flatMap((abschnitt) => abschnitt.schrittIds),
); );
return prisma.$transaction(async (tx) => { const angelegt = await prisma.$transaction(async (tx) => {
const lauf = await tx.lauf.create({ const lauf = await tx.lauf.create({
data: { data: {
vorlagenVersionId: version.id, vorlagenVersionId: version.id,
@@ -548,6 +574,12 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
return lauf; return lauf;
}); });
// Zugewiesen heisst: jemand soll es erfahren.
if (zugewiesenAnId) {
await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, angelegt.id, benutzer.id);
}
return angelegt;
} }
/** /**
@@ -766,9 +798,9 @@ export async function abschlussPruefenNachAenderung(
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben. * Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
*/ */
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> { export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
return prisma.$transaction(async (tx) => { const abgeschlossen = await prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return lauf; if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
const offen = await offenePflichtschritte(tx, laufId); const offen = await offenePflichtschritte(tx, laufId);
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen)); if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
@@ -798,8 +830,17 @@ export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenut
nachher: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt.toISOString() }, nachher: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt.toISOString() },
}, },
}); });
return nachher; return { lauf: nachher, warSchonFertig: false };
}); });
/*
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
* unveraendert zurueck — eine zweite Mail darueber waere falsch.
*/
if (!abgeschlossen.warSchonFertig) {
await meldenOhneBruch(Meldeanlass.LAUF_ABGESCHLOSSEN, laufId, benutzer.id);
}
return abgeschlossen.lauf;
} }
/** /**
@@ -924,7 +965,7 @@ export async function laufKopfdatenAendern(
aenderung: LaufKopfdaten, aenderung: LaufKopfdaten,
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
): Promise<Lauf> { ): Promise<Lauf> {
return prisma.$transaction(async (tx) => { const geaendertesErgebnis = await prisma.$transaction(async (tx) => {
const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer); const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer);
if ( if (
@@ -1018,7 +1059,7 @@ export async function laufKopfdatenAendern(
} }
} }
if (geaendert.length === 0) return vorher; if (geaendert.length === 0) return { lauf: vorher, neuZugewiesenAn: null };
const nachher = await tx.lauf.update({ where: { id: laufId }, data: daten }); const nachher = await tx.lauf.update({ where: { id: laufId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
@@ -1034,8 +1075,17 @@ export async function laufKopfdatenAendern(
nachher: kopfAuszug(nachher, geaendert), nachher: kopfAuszug(nachher, geaendert),
}, },
}); });
return nachher; return {
lauf: nachher,
// Nur eine **neue** Zuweisung an eine Person meldet; ein Entzug nicht.
neuZugewiesenAn: geaendert.includes("Zuweisung") ? nachher.zugewiesenAnId : null,
};
}); });
if (geaendertesErgebnis.neuZugewiesenAn) {
await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, laufId, benutzer.id);
}
return geaendertesErgebnis.lauf;
} }
/** /**
+66
View File
@@ -0,0 +1,66 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Eine Sperre in der Datenbank, ueber Prozesse und Instanzen hinweg (#016, #034).
*
* Erst war sie Teil des Abgleichs; mit der Zustell-Warteschlange braucht sie ein
* zweiter Aufrufer, und zwei Fassungen derselben Mechanik waere die schlechteste
* aller Loesungen. Die Tabelle heisst weiter `AbgleichSperre` — ein Umbenennen
* waere eine Migration, die nichts verbessert, und der Schluessel sagt ohnehin,
* wer gerade sperrt (`abgleich:<quelleId>`, `zustellung:warteschlange`).
*
* **In der Datenbank und nicht im Prozess**, weil hinter einem Lastverteiler
* zwei Instanzen laufen koennen. Ein `let laeuft = false` im Modul wuesste nichts
* von der zweiten.
*/
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Durchgaenge im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Durchgang, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
+83
View File
@@ -0,0 +1,83 @@
import cron, { type ScheduledTask } from "node-cron";
import { ueberfaelligeEinreihen, warteschlangeAbarbeiten } from "@/server/services/benachrichtigungen";
/**
* Der Zeitplan der Benachrichtigungen (#034).
*
* **Im selben Prozess** wie die Anwendung und wie der Abgleich-Zeitplan — kein
* zweiter Dienst, aus demselben Grund wie dort: beide Betriebswege dieses
* Projekts starten genau einen Prozess, und eine zweite Einheit waere eine
* zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL` hinterlegen muss.
*
* Ein eigener Zeitplan und nicht derselbe wie der Abgleich: der eine darf nicht
* warten, weil der andere gerade ein grosses Verzeichnis liest. Zwei Takte sind
* billig, ein verzoegerter Mailversand nicht.
*
* Drei Dinge halten das zusammen:
*
* 1. **Die Sperre setzt der Dienst, nicht der Zeitplan.**
* `warteschlangeAbarbeiten` nimmt sie selbst — in der Datenbank und damit
* auch ueber zwei Instanzen hinweg. Eine zweite Sperre hier waere schlimmer
* als keine: sie truege einen anderen Schluessel und liefe an der ersten
* vorbei.
* 2. **Ein Fehler beendet den Zeitplan nicht.** Ein Mailserver, der gerade nicht
* antwortet, ist der Normalfall im Betrieb; was passiert ist, steht an der
* Zustellung.
* 3. **Ueberfaelligkeit wird jede Minute geprueft, aber je Lauf nur einmal
* gemeldet.** Dafuer sorgt der partielle eindeutige Index
* `Zustellung_ueberfaellig_einmal` — ohne ihn bekaeme der Zustaendige fuer
* einen Lauf, der ueber das Wochenende steht, 2880 Mails.
*/
/** Jede Minute. Was wirklich faellig ist, entscheidet die Datenbank. */
const TAKT = "* * * * *";
let plan: ScheduledTask | undefined;
/**
* Ein Durchgang: ueberfaellige einreihen, dann die Warteschlange abarbeiten.
*
* In dieser Reihenfolge, damit eine gerade erkannte Ueberfaelligkeit nicht erst
* eine Minute spaeter hinausgeht. Exportiert, damit ein Test den Durchgang
* aufrufen kann, ohne eine Minute zu warten.
*/
export async function meldungsDurchgang(): Promise<void> {
try {
await ueberfaelligeEinreihen();
} catch (fehler) {
console.error(
"Überfällige Läufe konnten nicht eingereiht werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
try {
/*
* `undefined` heisst: eine andere Instanz ist dran. Kein Fehler und keine
* Zeile im Protokoll — im Normalbetrieb mit zwei Instanzen waere das jede
* Minute eine.
*/
await warteschlangeAbarbeiten();
} catch (fehler) {
console.error(
"Die Zustell-Warteschlange konnte nicht abgearbeitet werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
}
export function meldungsplanStarten(): void {
if (plan) return;
plan = cron.schedule(TAKT, () => {
void meldungsDurchgang();
});
}
export function meldungsplanAnhalten(): void {
plan?.stop();
plan = undefined;
}
export function meldungsplanLaeuft(): boolean {
return plan !== undefined;
}
+13 -16
View File
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums"; import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
AbgleichLaeuftBereitsFehler, // Seit #034 liegt die Sperre in ihrem eigenen Modul: die Zustell-Warteschlange
abgleichAusfuehren, // braucht dieselbe Mechanik, und zwei Fassungen davon waeren die schlechteste
faelligeQuellen, // aller Loesungen.
mitAbgleichSperre, import { mitSperre } from "@/server/services/sperre";
} from "@/server/services/abgleich";
import type { HandelnderBenutzer } from "@/server/mandant"; import type { HandelnderBenutzer } from "@/server/mandant";
import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis"; import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -99,14 +98,14 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
const [haeltDieSperre, ersterIstDrin] = signal(); const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal(); const [freigeben, darfWeiter] = signal();
const erster = mitAbgleichSperre("abgleich:test", 5, async () => { const erster = mitSperre("abgleich:test", 5, async () => {
haeltDieSperre(); haeltDieSperre();
await darfWeiter; await darfWeiter;
return "erster"; return "erster";
}); });
await ersterIstDrin; await ersterIstDrin;
const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter"); const zweiter = await mitSperre("abgleich:test", 5, async () => "zweiter");
freigeben(); freigeben();
// Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis. // Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis.
@@ -114,26 +113,26 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
expect(await erster).toBe("erster"); expect(await erster).toBe("erster");
// Danach ist die Sperre wieder frei. // Danach ist die Sperre wieder frei.
expect(await testPrisma.abgleichSperre.count()).toBe(0); expect(await testPrisma.abgleichSperre.count()).toBe(0);
expect(await mitAbgleichSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter"); expect(await mitSperre("abgleich:test", 5, async () => "dritter")).toBe("dritter");
}); });
it("sperrt je Schluessel und nicht global", async () => { it("sperrt je Schluessel und nicht global", async () => {
const [haeltDieSperre, ersterIstDrin] = signal(); const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal(); const [freigeben, darfWeiter] = signal();
const eine = mitAbgleichSperre("abgleich:eine", 5, async () => { const eine = mitSperre("abgleich:eine", 5, async () => {
haeltDieSperre(); haeltDieSperre();
await darfWeiter; await darfWeiter;
return "eine"; return "eine";
}); });
await ersterIstDrin; await ersterIstDrin;
expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere"); expect(await mitSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
freigeben(); freigeben();
expect(await eine).toBe("eine"); expect(await eine).toBe("eine");
}); });
it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => { it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => {
await expect( await expect(
mitAbgleichSperre("abgleich:test", 5, async () => { mitSperre("abgleich:test", 5, async () => {
throw new Error("mitten im Abgleich"); throw new Error("mitten im Abgleich");
}), }),
).rejects.toThrow("mitten im Abgleich"); ).rejects.toThrow("mitten im Abgleich");
@@ -151,7 +150,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000), laeuftAbAm: new Date(Date.now() - 60 * 60 * 1000),
}, },
}); });
expect(await mitAbgleichSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen"); expect(await mitSperre("abgleich:test", 5, async () => "uebernommen")).toBe("uebernommen");
expect(await testPrisma.abgleichSperre.count()).toBe(0); expect(await testPrisma.abgleichSperre.count()).toBe(0);
}); });
@@ -163,9 +162,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000), laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
}, },
}); });
expect( expect(await mitSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen")).toBeUndefined();
await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"),
).toBeUndefined();
// Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem // Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem
// anderen Lauf. // anderen Lauf.
const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({ const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({
+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);
});
});
+960
View File
@@ -0,0 +1,960 @@
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Meldeanlass,
VersionStatus,
Zustellweg,
Zustellzustand,
} from "@/generated/prisma/enums";
import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur";
import { verschluesseln } from "@/lib/geheimnisse";
import {
WARTEZEITEN_MINUTEN,
abgleichMeldungEinreihen,
laufMeldungEinreihen,
meldungseinstellungenHolen,
meldungseinstellungenSpeichern,
naechsterVersuch,
ueberfaelligeEinreihen,
warteschlangeAbarbeiten,
} from "@/server/services/benachrichtigungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Fuenf Zusagen tragen das Ganze, und jede hat hier ihren Test:
*
* 1. Die vier Anlaesse gehen an die richtigen Empfaenger — **gegen einen echten
* SMTP-Faenger**, nicht gegen eine abgefangene Funktion.
* 2. In **keiner** Nachricht steht ein Schrittinhalt und damit kein erzeugtes
* Passwort.
* 3. Eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar.
* 4. Zwei gleichzeitige Durchgaenge schicken nichts zweimal.
* 5. Wer Benachrichtigungen abgeschaltet hat, bekommt nichts.
*/
const MAILPIT = "http://127.0.0.1:8025";
const SMTP_PORT = 1025;
/**
* Steht der Faenger?
*
* **Auf Modulebene geprueft, nicht in `beforeAll`.** `it.runIf(...)` wird beim
* Einlesen der Datei ausgewertet — ein in `beforeAll` gesetztes Flag kaeme zu
* spaet, und alle Mailtests waeren stillschweigend uebersprungen. Genau so ist
* es beim ersten Lauf passiert: 3 skipped, gruener Durchlauf, nichts geprueft.
*/
async function mailpitAntwortet(): Promise<boolean> {
try {
const antwort = await fetch(`${MAILPIT}/api/v1/info`, { signal: AbortSignal.timeout(2_000) });
return antwort.ok;
} catch {
return false;
}
}
const mailpitDa = await mailpitAntwortet();
if (!mailpitDa && process.env["CI"]) {
throw new Error(
"Mailpit antwortet nicht auf 8025. In CI duerfen die Mailtests nicht uebersprungen werden — " +
"`docker compose -f docker-compose.test.yml up -d --wait mailpit`.",
);
}
if (!mailpitDa) {
console.warn(
"Mailpit laeuft nicht; die Mailtests werden uebersprungen. " +
"Start: docker compose -f docker-compose.test.yml up -d --wait mailpit",
);
}
type MailpitNachricht = {
readonly ID: string;
readonly Subject: string;
readonly To: readonly { readonly Address: string }[];
};
async function mailpitLeeren(): Promise<void> {
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
}
async function mailpitNachrichten(): Promise<MailpitNachricht[]> {
const antwort = await fetch(`${MAILPIT}/api/v1/messages?limit=100`);
const daten = (await antwort.json()) as { messages?: MailpitNachricht[] };
return daten.messages ?? [];
}
/** Der Klartext einer Nachricht – fuer die Passwort-Pruefung. */
async function mailpitText(id: string): Promise<string> {
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
const daten = (await antwort.json()) as { Text?: string; Subject?: string };
return `${daten.Subject ?? ""}\n${daten.Text ?? ""}`;
}
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Signatur eines Webhooks", () => {
const GEHEIMNIS = "sehr-geheim";
const RUMPF = '{"anlass":"LAUF_ZUGEWIESEN"}';
const JETZT = 1_762_334_400;
it("lässt sich mit dem Geheimnis nachrechnen", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(kopf).toMatch(/^t=1762334400,v1=[0-9a-f]{64}$/);
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: true });
});
it("mit einem falschen Geheimnis nicht", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen("falsch", RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "falsch",
});
});
it("und auch nicht, wenn der Rumpf nachträglich geändert wurde", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen(GEHEIMNIS, '{"anlass":"ETWAS_ANDERES"}', kopf, JETZT).gueltig).toBe(false);
});
it("weist eine zu alte und eine zu junge Nachricht ab", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
// Zu alt: eine abgefangene Nachricht soll sich nicht morgen einspielen lassen.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
/*
* Und zu jung. Nur „zu alt“ abzufangen liesse eine Nachricht mit Zeitstempel
* im Jahr 2040 fuer immer gueltig sein.
*/
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT - 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
// Innerhalb der Toleranz geht beides.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 299).gueltig).toBe(true);
});
it("weist einen kaputten Kopf ab, statt zu werfen", () => {
for (const kopf of ["", "unsinn", "t=abc,v1=ff", "v1=ff", "t=1762334400"]) {
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "form",
});
}
});
it("signiert den Zeitstempel mit", () => {
/*
* Stuende er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu
* brechen. Dieselbe Signatur mit einem anderen `t` muss also scheitern.
*/
const echt = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
const geschoben = echt.replace(`t=${JETZT}`, `t=${JETZT + 10}`);
expect(signaturPruefen(GEHEIMNIS, RUMPF, geschoben, JETZT + 10)).toEqual({
gueltig: false,
grund: "falsch",
});
});
});
describe("Die Texte der Meldungen", () => {
const KOPF = {
titel: "PC Neuinstallation – PC-001",
geraet: "PC-001",
person: "Maria Huber",
faelligAm: new Date("2026-10-07T10:00:00Z"),
vorlage: "PC Neuinstallation",
handelnder: "Petra Klein",
};
it("nennt den Anlass im Betreff", () => {
expect(laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").betreff).toContain("ist dir zugewiesen");
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").betreff).toContain("überfällig");
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").betreff).toContain("abgeschlossen");
expect(abgleichMeldung("AD Zentrale", "Verbindung abgelehnt", "u").betreff).toContain("fehlgeschlagen");
});
it("schreibt das Fälligkeitsdatum deutsch", () => {
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf).toContain("07.10.2026");
});
it("lässt leere Felder weg, statt „null“ zu schreiben", () => {
const dürftig = laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, { titel: "Nur ein Titel" }, "u");
expect(dürftig.rumpf).not.toContain("null");
expect(dürftig.rumpf).not.toContain("Gerät:");
expect(dürftig.rumpf).not.toContain("undefined");
});
it("trägt den Link, damit die Mail zu etwas führt", () => {
const link = "https://laufzettel.firma.de/laeufe/abc";
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, link).rumpf).toContain(link);
expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link);
});
it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => {
/*
* Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber
* die **Form**: `laufMeldung` nimmt nur Kopfdaten. Dass auch nichts aus der
* Datenbank durchsickert, prueft der Test „in keiner Nachricht steht ein
* erzeugtes Passwort“ weiter unten — ueber alle Rumpftexte, wie in #012.
*/
const alle = [
laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").rumpf,
abgleichMeldung("AD", "Grund", "u").rumpf,
].join("\n");
expect(alle).not.toContain("{{");
expect(alle).not.toContain("Password");
expect(alle).not.toContain("New-ADUser");
});
it("baut einen Webhook-Rumpf mit dem Anlass als Feld", () => {
const rumpf = webhookRumpf(
Meldeanlass.LAUF_ABGESCHLOSSEN,
"Betreff",
"https://x/laeufe/1",
{ laufId: "l1" },
new Date("2026-10-05T08:00:00Z"),
);
expect(JSON.parse(rumpf)).toEqual({
anlass: "LAUF_ABGESCHLOSSEN",
betreff: "Betreff",
link: "https://x/laeufe/1",
laufId: "l1",
verzeichnisquelleId: null,
zeitpunkt: "2026-10-05T08:00:00.000Z",
});
});
});
describe("Die Wiederholungsregel", () => {
const JETZT = new Date("2026-10-05T08:00:00Z");
it("wartet nach dem ersten Fehlschlag 1 Minute, dann 5, 30 und 120", () => {
expect(WARTEZEITEN_MINUTEN).toEqual([1, 5, 30, 120]);
for (const [index, minuten] of WARTEZEITEN_MINUTEN.entries()) {
// `versucheGesamt` zaehlt den gerade gescheiterten mit: 1 → eine Minute.
const ergebnis = naechsterVersuch(index + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(ergebnis.naechsterVersuchAm.getTime() - JETZT.getTime()).toBe(minuten * 60_000);
}
});
it("gibt nach dem fünften Fehlschlag auf — aber sichtbar", () => {
const ergebnis = naechsterVersuch(WARTEZEITEN_MINUTEN.length + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.AUFGEGEBEN);
// Eine stille Warteschlange ist schlimmer als keine: der Zeitpunkt bleibt
// stehen, damit die Liste ihre Reihenfolge behaelt.
expect(ergebnis.naechsterVersuchAm).toEqual(JETZT);
});
});
/* ------------------------------------------------------------------ */
/* Mit Datenbank */
/* ------------------------------------------------------------------ */
const APP_URL_VORHER = process.env["APP_URL"];
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: "Petra Klein", rolle: "ADMIN" },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
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: "Konto", position: 0 },
});
/*
* Ein Befehlsschritt mit dem Platzhalter – damit der Passwort-Test etwas zu
* finden haette, wenn irgendwo ein Schrittinhalt in eine Mail geriete.
*/
const schritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: "BEFEHL",
pflicht: true,
position: 0,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}" -Name "Maria Huber"',
},
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { kunde, admin, techniker, versionId: version.id, schrittId: schritt.id };
}
async function laufAnlegenRoh(
versionId: string,
kundeId: string,
daten: {
titel?: string;
zugewiesenAnId?: string | null;
erstelltVonId?: string | null;
faelligAm?: Date | null;
status?: LaufStatus;
} = {},
) {
return testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId,
titel: daten.titel ?? "PC Neuinstallation – PC-001",
zugewiesenAnId: daten.zugewiesenAnId ?? null,
erstelltVonId: daten.erstelltVonId ?? null,
faelligAm: daten.faelligAm ?? null,
status: daten.status ?? LaufStatus.OFFEN,
/*
* Die Zeitstempel gehoeren zum Status — `Lauf_begonnen_mit_zeitpunkt` und
* `Lauf_abgeschlossen_mit_zeitpunkt` verlangen das in der Datenbank. Ein
* Status ohne seinen Zeitpunkt ist hier gar nicht anlegbar, und das ist
* richtig so.
*/
...(daten.status === LaufStatus.IN_ARBEIT ? { gestartetAm: new Date() } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN
? { gestartetAm: new Date(), abgeschlossenAm: new Date() }
: {}),
},
});
}
/** SMTP auf den Faenger zeigen lassen. */
async function smtpEinrichten(): Promise<void> {
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
smtpHost: "127.0.0.1",
smtpPort: SMTP_PORT,
smtpTls: false,
smtpAbsender: "laufzettel@firma.example",
},
});
}
describe.runIf(datenbankVerfuegbar)("Einstellungen der Benachrichtigungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("gibt das SMTP-Passwort nie heraus, nur ob eines gesetzt ist", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{
smtpHost: "mail.firma.de",
smtpPort: 587,
smtpBenutzer: "laufzettel",
smtpPasswort: "streng-geheim",
smtpAbsender: "laufzettel@firma.de",
},
admin.id,
);
const gelesen = await meldungseinstellungenHolen();
expect(gelesen.smtp.host).toBe("mail.firma.de");
expect(gelesen.smtp.passwortGesetzt).toBe(true);
// Der Wert selbst taucht in der Antwort nirgends auf.
expect(JSON.stringify(gelesen)).not.toContain("streng-geheim");
// Und in der Datenbank steht er verschlüsselt, nicht im Klartext.
const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } });
expect(zeile.smtpPasswortVerschluesselt).toMatch(/^v[0-9]+\./);
expect(zeile.smtpPasswortVerschluesselt).not.toContain("streng-geheim");
});
it("schreibt kein Geheimnis ins Protokoll", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{ smtpHost: "mail.firma.de", smtpPort: 587, smtpPasswort: "streng-geheim" },
admin.id,
);
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.de/laufzettel", webhookGeheimnis: "hook-geheim" },
admin.id,
);
const protokoll = await testPrisma.protokoll.findMany();
const alsText = JSON.stringify(protokoll);
// Das Protokoll ist ansehbar (#023) – hier wäre die Verschlüsselung sinnlos.
expect(alsText).not.toContain("streng-geheim");
expect(alsText).not.toContain("hook-geheim");
expect(alsText).toContain("Benachrichtigungen geändert");
});
it("weist eine unbrauchbare Webhook-Adresse und einen falschen Port ab", async () => {
const { admin } = await aufbau();
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "nicht-mal-eine-url" }, admin.id),
).rejects.toThrow(/gültige URL/);
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "ftp://hook.firma.de" }, admin.id),
).rejects.toThrow(/http/);
await expect(
meldungseinstellungenSpeichern({ smtpHost: "mail.firma.de", smtpPort: 70_000 }, admin.id),
).rejects.toThrow(/zwischen 1 und 65535/);
});
});
describe.runIf(datenbankVerfuegbar)("Einreihen", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet eine Zuweisung an den Zugewiesenen, nicht an den Zuweisenden", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany();
expect(zustellungen).toHaveLength(1);
expect(zustellungen[0]).toMatchObject({
anlass: Meldeanlass.LAUF_ZUGEWIESEN,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
zustand: Zustellzustand.OFFEN,
versuche: 0,
});
expect(zustellungen[0]?.rumpf).toContain("https://laufzettel.firma.example/laeufe/");
expect(zustellungen[0]?.rumpf).toContain("Petra Klein");
});
it("schickt niemandem eine Mail über die eigene Tat", async () => {
/*
* Der haeufigste Grund, Benachrichtigungen ganz abzuschalten: Post ueber
* das, was man gerade selbst geklickt hat.
*/
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
const erzeugt = await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, techniker.id);
expect(erzeugt).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(0);
});
it("meldet einen Abschluss auch dem, der den Lauf angelegt hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
// Abgeschlossen hat der Techniker – also erfaehrt es der Ersteller.
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
const empfaenger = (await testPrisma.zustellung.findMany()).map((z) => z.empfaenger);
expect(empfaenger).toEqual(["admin@firma.example"]);
});
it("schickt nichts an jemanden, der Benachrichtigungen abgeschaltet hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({
where: { id: techniker.id },
data: { meldungenAktiv: false },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("schickt nichts an ein deaktiviertes Konto", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { aktiv: false } });
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("achtet die Mandantengrenze", async () => {
/*
* Eine Meldung traegt Lauftitel und Geraet. Ohne diese Pruefung waere die
* Benachrichtigung der eine Weg nach draussen, den `sichtbareKundenIds`
* nicht abdeckt.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
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" },
});
// Der Techniker darf nur den eigenen Kunden sehen …
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kunde.id },
});
// … ist aber einem Lauf des fremden zugewiesen.
const lauf = await laufAnlegenRoh(versionId, fremd.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("meldet einen fehlgeschlagenen Abgleich allen Administratoren", async () => {
const { kunde, admin } = await aufbau();
const zweiterAdmin = await testPrisma.benutzer.create({
data: { email: "admin2@firma.example", name: "Zweiter Admin", rolle: "ADMIN" },
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { empfaenger: "asc" } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual(["admin2@firma.example", "admin@firma.example"]);
expect(zustellungen[0]?.rumpf).toContain("Verbindung abgelehnt");
expect(zustellungen[0]?.verzeichnisquelleId).toBe(quelle.id);
expect(admin.id).toBeTruthy();
expect(zweiterAdmin.id).toBeTruthy();
});
it("meldet einen überfälligen Lauf genau einmal, auch bei jedem Durchgang", async () => {
/*
* Der Zeitplan sieht **jede Minute** nach. Ohne den partiellen eindeutigen
* Index bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende
* steht, 2880 Mails — und jede einzelne davon waere fuer sich korrekt.
*/
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
expect(await ueberfaelligeEinreihen()).toBe(1);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(1);
});
it("hält die Einmaligkeit auch gegen einen Schreiber an der Service-Schicht vorbei", async () => {
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await ueberfaelligeEinreihen();
await expect(
testPrisma.zustellung.create({
data: {
anlass: Meldeanlass.LAUF_UEBERFAELLIG,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
betreff: "noch einmal",
rumpf: "noch einmal",
laufId: lauf.id,
benutzerId: techniker.id,
},
}),
).rejects.toThrow();
});
it("meldet einen überfälligen Lauf nicht, wenn er abgeschlossen ist", async () => {
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.ABGESCHLOSSEN,
});
expect(await ueberfaelligeEinreihen()).toBe(0);
});
it("legt für einen Lauf ohne Zuweisung keine Zeile an", async () => {
const { kunde, admin, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: null });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("reiht neben der Mail eine Webhook-Zeile ein, wenn einer eingerichtet ist", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.example/laufzettel", webhookGeheimnis: "geheim" },
admin.id,
);
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { weg: "asc" } });
expect(zustellungen.map((z) => z.weg)).toEqual([Zustellweg.MAIL, Zustellweg.WEBHOOK]);
const hook = zustellungen.find((z) => z.weg === Zustellweg.WEBHOOK);
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
expect(hook?.benutzerId).toBeNull();
expect(JSON.parse(hook?.rumpf ?? "{}")).toMatchObject({ anlass: "LAUF_ZUGEWIESEN" });
});
});
/* ------------------------------------------------------------------ */
/* Senden */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Die Warteschlange", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
if (mailpitDa) await mailpitLeeren();
});
it.runIf(mailpitDa)("schickt alle vier Anlässe an die richtigen Empfänger", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis).toMatchObject({ fehlgeschlagen: 0, aufgegeben: 0 });
expect(ergebnis?.gesendet).toBe(4);
const nachrichten = await mailpitNachrichten();
expect(nachrichten).toHaveLength(4);
const nachEmpfaenger = new Map<string, string[]>();
for (const nachricht of nachrichten) {
for (const an of nachricht.To) {
nachEmpfaenger.set(an.Address, [...(nachEmpfaenger.get(an.Address) ?? []), nachricht.Subject]);
}
}
/*
* Der Techniker: zugewiesen und überfällig. Der Admin: abgeschlossen (er hat
* den Lauf angelegt) und der Abgleich.
*
* Geprueft wird **ohne Annahme ueber die Reihenfolge** — die haengt an der
* Sortierung der Betreffe, und die hatte ich beim ersten Lauf falsch
* geraten. Eine Sortierung ist hier auch nicht die Zusage; die Zusage ist,
* wer was bekommt.
*/
const anTechniker = nachEmpfaenger.get("technik@firma.example") ?? [];
expect(anTechniker).toHaveLength(2);
expect(anTechniker.some((betreff) => betreff.includes("ist dir zugewiesen"))).toBe(true);
expect(anTechniker.some((betreff) => betreff.includes("überfällig"))).toBe(true);
const anAdmin = nachEmpfaenger.get("admin@firma.example") ?? [];
expect(anAdmin).toHaveLength(2);
expect(anAdmin.some((betreff) => betreff.includes("abgeschlossen"))).toBe(true);
expect(anAdmin.some((betreff) => betreff.includes("Verzeichnis-Abgleich"))).toBe(true);
// Und alle Zeilen stehen jetzt auf GESENDET, mit Zeitpunkt.
const zeilen = await testPrisma.zustellung.findMany();
expect(zeilen).toHaveLength(4);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(Zustellzustand.GESENDET);
expect(zeile.gesendetAm).toBeInstanceOf(Date);
expect(zeile.versuche).toBe(1);
}
});
it.runIf(mailpitDa)("hat in keiner Nachricht ein erzeugtes Passwort", async () => {
/*
* Ueber **alle** Rumpftexte, wie in #012. Die Vorlage im Aufbau hat einen
* Befehlsschritt mit `{{generiertes_passwort}}` und `New-ADUser` — kaeme
* irgendwo ein Schrittinhalt in eine Mail, stuende es hier.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await warteschlangeAbarbeiten();
const nachrichten = await mailpitNachrichten();
expect(nachrichten.length).toBeGreaterThan(0);
for (const nachricht of nachrichten) {
const text = await mailpitText(nachricht.ID);
expect(text).not.toContain("{{generiertes_passwort}}");
expect(text).not.toContain("New-ADUser");
expect(text).not.toContain("AccountPassword");
// Auch kein aufgeloestes Passwort: 20 Zeichen aus dem Alphabet von #012.
expect(text).not.toMatch(/-AccountPassword/);
}
// Und in der Datenbank steht es auch nicht.
const alsText = JSON.stringify(await testPrisma.zustellung.findMany());
expect(alsText).not.toContain("{{");
expect(alsText).not.toContain("New-ADUser");
});
it("wiederholt eine fehlgeschlagene Zustellung und macht sie danach sichtbar", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
// **Kein** SMTP eingerichtet: jeder Versuch muss scheitern.
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* **Nicht** ein festes Datum: `naechsterVersuchAm` steht beim Einreihen auf
* `now()` der Datenbank. Ein Zeitpunkt in der Vergangenheit liesse den
* Durchgang die Zeile gar nicht erst anfassen — beim ersten Lauf war der
* Test deshalb rot, und zwar ohne Fehler im Code.
*/
const jetzt = new Date(Date.now() + 1_000);
const erster = await warteschlangeAbarbeiten(jetzt);
expect(erster).toMatchObject({ gesendet: 0, fehlgeschlagen: 1, aufgegeben: 0 });
let zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.versuche).toBe(1);
expect(zeile.letzterFehler).toContain("SMTP");
// Eine Minute später, nicht sofort.
expect(zeile.naechsterVersuchAm.getTime()).toBe(jetzt.getTime() + 60_000);
// Vor der Wartezeit fasst der Durchgang die Zeile nicht an.
expect(await warteschlangeAbarbeiten(jetzt)).toMatchObject({ gesendet: 0, fehlgeschlagen: 0 });
expect((await testPrisma.zustellung.findFirstOrThrow()).versuche).toBe(1);
// Danach die Versuche zwei bis vier, jeder nach seiner Wartezeit.
for (const erwarteteWartezeit of WARTEZEITEN_MINUTEN.slice(1)) {
const spaeter = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const vorherVersuche = zeile.versuche;
await warteschlangeAbarbeiten(spaeter);
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.versuche).toBe(vorherVersuche + 1);
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.naechsterVersuchAm.getTime() - spaeter.getTime()).toBe(erwarteteWartezeit * 60_000);
}
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length);
// Der fünfte Fehlschlag gibt auf – und bleibt sichtbar.
const zuletzt = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const ergebnis = await warteschlangeAbarbeiten(zuletzt);
expect(ergebnis).toMatchObject({ aufgegeben: 1 });
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.AUFGEGEBEN);
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length + 1);
expect(zeile.letzterFehler).toBeTruthy();
// Und wird nicht mehr angefasst.
expect(await warteschlangeAbarbeiten(new Date(zuletzt.getTime() + 86_400_000))).toMatchObject({
gesendet: 0,
fehlgeschlagen: 0,
aufgegeben: 0,
});
});
it.runIf(mailpitDa)("schickt bei zwei gleichzeitigen Durchgängen nichts zweimal", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* Beide zugleich starten. Einer bekommt die Sperre, der andere `undefined` —
* ohne sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
const [eins, zwei] = await Promise.all([warteschlangeAbarbeiten(), warteschlangeAbarbeiten()]);
const ergebnisse = [eins, zwei];
expect(ergebnisse.filter((e) => e === undefined)).toHaveLength(1);
expect(ergebnisse.find((e) => e !== undefined)).toMatchObject({ gesendet: 1 });
expect(await mailpitNachrichten()).toHaveLength(1);
});
});
/* ------------------------------------------------------------------ */
/* Webhook gegen einen echten Empfaenger */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Der Webhook", () => {
let server: Server | undefined;
let port = 0;
let empfangen: { kopf: string | undefined; rumpf: string }[] = [];
let antwortStatus = 200;
beforeAll(async () => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
server = createServer((anfrage, antwort) => {
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
empfangen.push({
kopf: anfrage.headers[SIGNATUR_KOPF.toLowerCase()] as string | undefined,
rumpf,
});
antwort.writeHead(antwortStatus).end(antwortStatus === 200 ? "ok" : "kaputt");
});
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
port = typeof adresse === "object" && adresse ? adresse.port : 0;
});
afterAll(async () => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(async () => {
await datenbankLeeren();
empfangen = [];
antwortStatus = 200;
});
afterEach(() => {
antwortStatus = 200;
});
it("kommt an, und die Signatur lässt sich mit dem Geheimnis nachrechnen", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
webhookUrl: `http://127.0.0.1:${port}/laufzettel`,
webhookGeheimnisVerschluesselt: verschluesseln("hook-geheim"),
},
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
// Nur die Webhook-Zeile: kein SMTP eingerichtet, die Mailzeile scheitert.
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis?.gesendet).toBe(1);
expect(empfangen).toHaveLength(1);
const nachricht = empfangen[0];
if (!nachricht?.kopf) throw new Error("Keine Signatur im Kopf.");
const jetzt = Math.floor(Date.now() / 1000);
expect(signaturPruefen("hook-geheim", nachricht.rumpf, nachricht.kopf, jetzt)).toEqual({
gueltig: true,
});
// Mit einem falschen Geheimnis nicht.
expect(signaturPruefen("falsch", nachricht.rumpf, nachricht.kopf, jetzt).gueltig).toBe(false);
expect(JSON.parse(nachricht.rumpf)).toMatchObject({
anlass: "LAUF_ZUGEWIESEN",
laufId: lauf.id,
});
});
it("gilt als fehlgeschlagen, wenn die Gegenseite nicht mit 2xx antwortet", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
antwortStatus = 500;
await warteschlangeAbarbeiten(new Date(Date.now() + 1_000));
const hook = await testPrisma.zustellung.findFirstOrThrow({ where: { weg: Zustellweg.WEBHOOK } });
expect(hook.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(hook.letzterFehler).toContain("500");
});
it("geht ohne Geheimnis hinaus, dann aber ohne Signatur", async () => {
/*
* Ein Webhook ohne Geheimnis ist zulaessig — manche Gegenstellen haben keine
* Pruefung. Dann fehlt die Kopfzeile, statt dass eine leere Signatur
* Sicherheit vortaeuscht.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await warteschlangeAbarbeiten();
expect(empfangen).toHaveLength(1);
expect(empfangen[0]?.kopf).toBeUndefined();
});
});
+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:?");
+47 -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(
@@ -71,6 +99,12 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Kunde_eigene_organisation_einmalig", "Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig", "Vorlage_seedSchluessel_einmalig",
"VorlagenVersion_ein_entwurf", "VorlagenVersion_ein_entwurf",
/*
* Ohne diesen bekaeme der Zustaendige fuer einen Lauf, der ueber das
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
*/
"Zustellung_ueberfaellig_einmal",
]); ]);
}); });
@@ -80,7 +114,12 @@ 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",
"Einstellung_smtp_passwort_verschluesselt",
"Einstellung_smtp_port_bereich",
"Einstellung_webhook_geheimnis_verschluesselt",
"Geraet_registriert_braucht_namen", "Geraet_registriert_braucht_namen",
"Geraet_tagcode_form", "Geraet_tagcode_form",
"Kunde_kuerzel_form", "Kunde_kuerzel_form",
@@ -93,6 +132,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"TagCodeVergabe_form", "TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt", "Verzeichnisquelle_bindpasswort_verschluesselt",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt", "VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer",
"Zustellung_gesendet_mit_zeitpunkt",
"Zustellung_versuche_nicht_negativ",
"Zustellung_webhook_ohne_benutzer",
]); ]);
}); });
+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");
});
});
+8 -3
View File
@@ -18,7 +18,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const abgleichAusfuehren = vi.fn(); const abgleichAusfuehren = vi.fn();
const faelligeQuellen = vi.fn(); const faelligeQuellen = vi.fn();
/** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */ /** Nur da, um zu belegen, dass der Zeitplan sie **nicht** nimmt. */
const mitAbgleichSperre = vi.fn(); const mitSperre = vi.fn();
/** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */ /** Dieselbe Klasse wie im Dienst – `instanceof` entscheidet ueber das Verhalten. */
class AbgleichLaeuftBereitsFehler extends Error {} class AbgleichLaeuftBereitsFehler extends Error {}
@@ -27,7 +27,12 @@ vi.mock("@/server/services/abgleich", () => ({
AbgleichLaeuftBereitsFehler, AbgleichLaeuftBereitsFehler,
abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args), abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...args),
faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args), faelligeQuellen: (...args: unknown[]) => faelligeQuellen(...args),
mitAbgleichSperre: (...args: unknown[]) => mitAbgleichSperre(...args), }));
// Seit #034 liegt die Sperre in `services/sperre`. Gemockt wird sie weiter, um
// zu belegen, dass der Zeitplan sie **nicht** selbst nimmt.
vi.mock("@/server/services/sperre", () => ({
mitSperre: (...args: unknown[]) => mitSperre(...args),
})); }));
const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } = const { abgleichDurchgang, zeitplanAnhalten, zeitplanLaeuft, zeitplanStarten } =
@@ -57,7 +62,7 @@ describe("Ein Durchgang des Zeitplans", () => {
* Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener * Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
* Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus. * Abgleich saehe fuer den Zeitplan wie ein unerwarteter Fehler aus.
*/ */
expect(mitAbgleichSperre).not.toHaveBeenCalled(); expect(mitSperre).not.toHaveBeenCalled();
/* /*
* Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung * Der handelnde „Benutzer“ ist der Systemlauf: ADMIN, damit die Rechtepruefung
* des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht * des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht
+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 () => {