Compare commits
3
Commits
v0.2.0
...
2d3325ef20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3325ef20 | ||
|
|
4aa79c93ff | ||
|
|
3cbd5026f8 |
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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** |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+19
@@ -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';
|
||||||
@@ -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
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:?");
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user