feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht
oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt
hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich
fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die
eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz
abzuschalten, und deshalb faellt der Handelnde immer aus der
Empfaengerliste.

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

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

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

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

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

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 17:22:20 +02:00
co-authored by Claude Opus 5
parent 4aa79c93ff
commit 2d3325ef20
36 changed files with 3573 additions and 130 deletions
+12
View File
@@ -53,6 +53,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### 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
+27 -10
View File
@@ -38,6 +38,7 @@ Der Auftrag nennt englische Modelle. Verbindlich gilt:
| DeviceTemplateDefault | `GeraetetypVorlage` | Comment | `Kommentar` |
| — (ergänzt) | `AbgleichSperre` | AuditLog | `Protokoll` |
| — (ergänzt) | `BenutzerKunde` | — (ergänzt) | `TagCodeVergabe` |
| — (ergänzt) | `Zustellung` | | |
Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER}` · `Betriebsmodus {INTERN DIENSTLEISTUNG}` ·
`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`) ·
`VariablenTyp {TEXT ZAHL AUSWAHL DATUM}` ·
`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.
@@ -98,9 +101,11 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
ist darstellbar.
- **Regeln stehen in der Datenbank, nicht nur im Service.** Zwölf CHECK-Bedingungen, zwei
partielle Eindeutigkeitsindizes und vier zusammengesetzte Fremdschlüssel halten fest, was auch
dann gelten muss, wenn jemand an der Service-Schicht vorbei schreibt. Die Mandantengrenze ist
- **Regeln stehen in der Datenbank, nicht nur im Service.** CHECK-Bedingungen, partielle
Eindeutigkeitsindizes, zusammengesetzte Fremdschlüssel und Trigger halten fest, was auch
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
fremden Kunden wählen. Der Service prüft dasselbe noch einmal, nur mit besserer Meldung.
@@ -122,6 +127,12 @@ Zwei Obergrenzen, die nicht angehoben werden dürfen:
Build-Skripte von Abhängigkeiten sind in `pnpm-workspace.yaml` unter `allowBuilds` freigegeben
(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
`middleware.ts` heißt **`proxy.ts`** und exportiert `proxy()`, Node-Laufzeit. `cookies()`,
@@ -177,15 +188,21 @@ speichern, neu laden, und nachsehen, ob es wirklich in der Datenbank steht.
## Vor jedem Commit
```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
```
**Der LDAP-Container gehört in die erste Zeile.** Ohne ihn überspringt die Suite **22 Tests** — den
ganzen Verzeichnis-Abgleich (nachgezählt: 81 statt 59 in `tests/abgleich.test.ts` und
`tests/verzeichnis.test.ts`). Sie sagt das auf stderr, aber der Lauf sieht grün aus, und wer nur
diesem Abschnitt folgte, hat den Abgleich nie geprüft. In CI bricht sie stattdessen ab, absichtlich.
Aufgefallen bei der Abnahme zu #019.
**Die beiden Container gehören in die erste Zeile.** Ohne sie überspringt die Suite Tests, sagt das
auf stderr — und sieht trotzdem grün aus. Wer nur diesem Abschnitt folgte, hätte das Betroffene nie
geprüft. In CI bricht sie stattdessen ab, absichtlich.
| 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
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+4 -3
View File
@@ -45,12 +45,13 @@ openssl rand -base64 48
### Qualitätsschranken
```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
```
Der LDAP-Container ist keine Zierde: ohne ihn überspringt die Suite den ganzen Verzeichnis-Abgleich
— sie sagt das auf stderr, aber der Lauf sieht grün aus. In CI bricht sie stattdessen ab.
Die beiden Container sind keine Zierde: ohne `ldap` überspringt die Suite den ganzen
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.
Ende-zu-Ende-Tests: `pnpm test:e2e` (verlangt einen gebauten, laufenden Stand — siehe
+24
View File
@@ -17,6 +17,30 @@ services:
timeout: 5s
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
# Schema: sAMAccountName, userPrincipalName, binaeres objectGUID,
# userAccountControl, objectCategory und department bringt OpenLDAP nicht mit,
+49
View File
@@ -169,6 +169,55 @@ nicht archivieren.
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
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
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
+33
View File
@@ -232,6 +232,39 @@ versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück
Sonderfall und keinen eigenen Fehlercode.
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
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
+2
View File
@@ -56,6 +56,8 @@ Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
- **[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
- **[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
+170
View File
@@ -0,0 +1,170 @@
# Benachrichtigungen einrichten
Laufzettel meldet vier Dinge, und nur diese vier:
| Anlass | Wer bekommt es |
|---|---|
| Ein Lauf wird jemandem **zugewiesen** | der Zugewiesene |
| Ein Lauf ist **überfällig** | der Zugewiesene, **einmal** je Lauf |
| Ein Lauf wird **abgeschlossen** | der Zugewiesene und wer ihn angelegt hat |
| Ein **Verzeichnisabgleich schlägt fehl** | alle aktiven Administratoren |
**Niemand bekommt Post über das, was er selbst gerade getan hat.** Das ist der häufigste Grund,
Benachrichtigungen ganz abzuschalten, und deshalb fällt der Handelnde immer aus der Empfängerliste.
> **In keiner Nachricht steht ein Schrittinhalt.** Titel, Status, Gerät, Person, Frist und ein Link —
> mehr nicht. Kein Wert, keine Notiz, kein Befehl, und damit auch kein `{{generiertes_passwort}}`.
> Das Passwort entsteht bei jeder Anzeige neu und wird nirgends gespeichert; eine Mail dagegen liegt
> für immer in einem Postfach, oft auf einem Server, der nicht im Haus steht.
## Was gebraucht wird
- **`APP_URL`**, damit der Link in der Mail irgendwohin führt. Fehlt sie, gehen die Meldungen
trotzdem hinaus — die Einstellungsseite sagt es dann deutlich.
- **Ein SMTP-Server** für Mail, **oder** eine Webhook-Adresse, **oder** beides. Ohne eines von beiden
wird nichts eingereiht; es entstehen also keine Zeilen, die nie ankommen.
## Mail über SMTP
Unter **Admin → Einstellungen → Benachrichtigungen**:
| Feld | Was hineingehört |
|---|---|
| Host | `mail.firma.de` — der Mailserver des Hauses |
| Port | `587` für StartTLS, `465` für TLS von Anfang an, `25` nur im eigenen Netz |
| Benutzer | leer lassen, wenn der Server dem Rechner ohne Anmeldung traut |
| Passwort | wird verschlüsselt gespeichert und **nie wieder angezeigt** |
| Absender | `laufzettel@firma.de` — muss der Server akzeptieren |
| TLS verlangen | an, außer der Server kann es wirklich nicht |
Dann **Speichern**, dann **Testmail schicken**. Die Testmail geht **direkt** hinaus und nicht über
die Warteschlange: ein Fehler erscheint sofort und im Klartext, statt eine Minute später in einer
Liste.
**Das Passwortfeld ist leer, obwohl ein Passwort gesetzt ist** — das ist Absicht. Leer heißt
„unverändert lassen“. Sonst löschte jedes Speichern der übrigen Felder das Passwort mit.
### Wenn die Testmail nicht ankommt
| Meldung | Ursache |
|---|---|
| `greeting never received` | `secure` passt nicht zum Port. Auf 587 gehört StartTLS, nicht TLS von Anfang an — Laufzettel entscheidet das selbst am Port, aber ein Server, der auf 465 StartTLS erwartet, ist falsch konfiguriert. |
| `535 Authentication failed` | Benutzer oder Passwort stimmen nicht. Manche Server wollen die vollständige Mailadresse als Benutzernamen. |
| `550 relay not permitted` | Der Server nimmt von dieser Maschine keine Mail für fremde Domains an. Entweder den Rechner freischalten oder sich anmelden. |
| `self signed certificate` | Der Mailserver hat ein eigenes Zertifikat. Entweder ein richtiges einrichten oder TLS für diesen Server abschalten — im eigenen Netz ist das zweite vertretbar. |
| `ECONNREFUSED` | Port falsch, oder eine Firewall dazwischen. |
## Webhook
Dieselben vier Anlässe als `POST` mit JSON-Rumpf:
```json
{
"anlass": "LAUF_ABGESCHLOSSEN",
"betreff": "Laufzettel abgeschlossen: „PC Neuinstallation – PC-001“",
"link": "https://laufzettel.firma.de/laeufe/cmu…",
"laufId": "cmu…",
"verzeichnisquelleId": null,
"zeitpunkt": "2026-10-05T08:00:00.000Z"
}
```
`anlass` steht vorn, damit sich ohne Auspacken filtern lässt. Auch hier: **keine Schrittinhalte.**
Als erfolgreich gilt nur eine Antwort mit `2xx`. Alles andere ist ein Fehlschlag und wird wiederholt.
### Die Signatur nachrechnen
Ist ein Geheimnis gesetzt, trägt jede Anfrage den Kopf:
```
X-Laufzettel-Signatur: t=1762334400,v1=9f86d08188...
```
Signiert wird `<t>.<rumpf>` mit HMAC-SHA256. **Der Zeitstempel ist Teil der signierten Nachricht**,
nicht Beiwerk — stünde er nur daneben, könnte ihn jeder ändern, ohne die Signatur zu brechen, und
eine abgefangene Nachricht ließe sich in einem Jahr noch einspielen.
So prüft die Gegenseite (Node, aber in jeder Sprache dasselbe):
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function pruefen(geheimnis, rumpf, kopf) {
const teile = Object.fromEntries(kopf.split(",").map((s) => s.split("=")));
const alter = Math.abs(Math.floor(Date.now() / 1000) - Number(teile.t));
if (alter > 300) return false; // älter als fünf Minuten: ablehnen
const erwartet = createHmac("sha256", geheimnis).update(`${teile.t}.${rumpf}`).digest("hex");
// Zeitkonstant vergleichen, nicht mit ===
return timingSafeEqual(Buffer.from(erwartet), Buffer.from(teile.v1));
}
```
Drei Dinge, die dabei schiefgehen:
1. **Den Rumpf vorher geparst und wieder serialisiert.** Dann stimmt die Signatur nicht mehr —
`JSON.stringify` setzt Leerzeichen und Reihenfolge anders. Signiert wird der **rohe** Rumpf, also
vor jeder Verarbeitung lesen.
2. **Mit `===` verglichen.** Das bricht beim ersten Unterschied ab; aus der Dauer ließe sich die
richtige Signatur Zeichen für Zeichen erraten. `timingSafeEqual` oder das Äquivalent benutzen.
3. **Nur „zu alt“ geprüft.** Eine Nachricht mit Zeitstempel im Jahr 2040 wäre dann für immer gültig.
Der Abstand gehört in **beide** Richtungen geprüft.
Ohne Geheimnis fehlt die Kopfzeile ganz, statt dass eine leere Signatur Sicherheit vortäuscht.
## Die Warteschlange
**Eingereiht wird sofort, gesendet nicht.** Ein hängender Mailserver darf kein Abhaken blockieren:
der Dienst schreibt eine Zeile, und ein Zeitplan im selben Prozess schickt sie — jede Minute, kein
zweiter Dienst, kein Systemd-Timer.
Ein Fehlschlag wird wiederholt, mit wachsendem Abstand: nach **1, 5, 30 und 120 Minuten**. Beim
fünften Fehlschlag gibt Laufzettel auf und lässt die Zeile als **aufgegeben** stehen.
> **Eine stille Warteschlange ist schlimmer als keine.** Unter **Admin → Zustellungen** steht, was
> hinausgegangen ist und was nicht — mit Grund. Sieh dort hin, wenn jemand sagt, er bekomme keine
> Mails: fast immer ist das SMTP-Passwort abgelaufen, und ohne diese Seite merkt es wochenlang
> niemand, weil jeder Lauf weiter funktioniert.
Betreff und Rumpf stehen **fertig** in der Zeile. Eine Meldung sagt, was damals war; würde der Text
erst beim Senden gebaut, stünde in einer Mail von heute der Stand von jetzt.
### Mehrere Instanzen
Läuft Laufzettel hinter einem Lastverteiler mehrfach, nimmt genau eine Instanz je Durchgang die
Warteschlange — über eine Sperre in der Datenbank, dieselbe Mechanik wie beim Verzeichnisabgleich.
Ohne sie bekäme jeder Empfänger jede Mail doppelt. Dafür ist nichts einzurichten.
## Je Person abschalten
Unter **Admin → Benutzer** schaltet der **Mail**-Knopf die Benachrichtigungen einer Person ab. Das
gilt auch für das eigene Konto — sich selbst zu deaktivieren wäre ein Eigentor, seine eigene Post
abzustellen ist der häufigste Wunsch überhaupt.
Wer abgeschaltet hat, bekommt keine Zeile und keinen Fehler: es wird gar nichts eingereiht. Ein
deaktiviertes Konto ebenso.
## Was nicht gemeldet wird
- **Ein Kommentar oder ein angehängtes Foto.** Das sieht die Live-Aktualisierung in der
Abarbeitungsansicht sofort; eine Mail dafür wäre Lärm.
- **Ein einzelner abgehakter Schritt.** Dasselbe.
- **Ein archivierter Lauf.** Archivieren ist eine Aufräumarbeit, keine Nachricht.
Mehr Anlässe wären ein Benachrichtigungsbaukasten, und der ist eine Phase für sich. Diese vier decken
ab, was im Betrieb wirklich fehlt.
## Prüfen, dass es läuft
```bash
# Steht etwas in der Warteschlange, das nicht hinausgeht?
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc \
"SELECT zustand, count(*) FROM \"Zustellung\" GROUP BY zustand"
```
Nativ dasselbe über `sudo -u postgres psql -d laufzettel -tAc "…"`.
Steht dort etwas unter `AUFGEGEBEN`, schau unter **Admin → Zustellungen** nach dem Grund. Alles unter
`OFFEN` oder `FEHLGESCHLAGEN` regelt der Zeitplan von selbst — es sei denn, es steht dort seit
Stunden, dann läuft der Prozess nicht oder `ZEITPLAN_AKTIV` ist auf `false`.
+65 -2
View File
@@ -32,12 +32,13 @@ als Unterpunkte mit Aufgabe und Dauer.
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
| | **Summe** | **50 h 05 min** |
| #034 | M21 – Benachrichtigungen per Mail und Webhook | 3 h 30 min |
| | **Summe** | **53 h 35 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 14 h 45 min | in Arbeit |
| v0.2.0 | 18 h 15 min | in Arbeit |
---
@@ -1239,3 +1240,65 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
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.
+70 -21
View File
@@ -1,10 +1,10 @@
# #034 M21 – Benachrichtigungen per Mail und Webhook
**Status:** offen
**Status:** erledigt
**Im Tracker:** [#54](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/54)
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend, frontend, infra
**Geschätzt:** 10 h **Tatsächlich:** –
**Geschätzt:** 10 h **Tatsächlich:** 3 h 30 min
## Ziel
@@ -14,44 +14,93 @@ Ein zugewiesener Lauf fällt heute nur auf, wenn jemand die Übersicht öffnet.
## 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
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
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
(`AbgleichSperre`) verhindert, dass zwei Instanzen dasselbe zweimal schicken.
## 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
- [ ] 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“
- [ ] 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
- [ ] 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
- [ ] Ü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
- [ ] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
- [ ] Admin: die letzten Zustellungen mit Zustand und Grund
- [ ] **`{{generiertes_passwort}}` kommt in keine Nachricht.** Eine Mail mit dem Lauf-Inhalt wäre
- [x] Je Benutzer abschaltbar; ein Benutzer ohne Mailadresse bekommt keine Mail und keinen Fehler
- [x] Admin: die letzten Zustellungen mit Zustand und Grund
- [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
enthält Titel, Status und einen Link — **keine Schrittinhalte**
- [ ] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
- [ ] Anleitung Kapitel 07
- [x] `docs/betrieb/`: SMTP einrichten, Webhook prüfen, Signatur nachrechnen
- [x] Anleitung Kapitel 07
## 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
- [ ] 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
- [ ] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
- [ ] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
- [ ] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
- [x] Test: eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar
- [x] Test: zwei gleichzeitige Durchgänge schicken nichts zweimal
- [x] Test: in **keiner** Nachricht steht ein erzeugtes Passwort — über alle Rumpftexte geprüft, wie
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.
+2 -2
View File
@@ -38,6 +38,6 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #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) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
| #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 | – |
| | **Summe** | | | **229 h** | **50 h 05 min** |
| | **Summe** | | | **229 h** | **53 h 35 min** |
+2
View File
@@ -54,6 +54,7 @@
"lucide-react": "1.47.0",
"next": "16.3.6",
"node-cron": "^4.6.0",
"nodemailer": "10.0.10",
"openid-client": "^6.8.8",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
@@ -72,6 +73,7 @@
"@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
+19
View File
@@ -220,6 +220,9 @@ importers:
node-cron:
specifier: ^4.6.0
version: 4.6.0
nodemailer:
specifier: 10.0.10
version: 10.0.10
openid-client:
specifier: ^6.8.8
version: 6.8.8
@@ -269,6 +272,9 @@ importers:
'@types/node':
specifier: ^24.13.6
version: 24.13.6
'@types/nodemailer':
specifier: ^8.0.2
version: 8.0.2
'@types/pg':
specifier: ^8.23.1
version: 8.23.1
@@ -2321,6 +2327,9 @@ packages:
'@types/node@24.13.6':
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':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -4113,6 +4122,10 @@ packages:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'}
nodemailer@10.0.10:
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
engines: {node: '>=20.0.0'}
oauth4webapi@3.8.8:
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
@@ -6880,6 +6893,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/nodemailer@8.0.2':
dependencies:
'@types/node': 24.13.6
'@types/pg@8.23.1':
dependencies:
'@types/node': 24.13.6
@@ -9011,6 +9028,8 @@ snapshots:
node-releases@2.0.57: {}
nodemailer@10.0.10: {}
oauth4webapi@3.8.8: {}
object-assign@4.1.1: {}
@@ -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';
+101 -4
View File
@@ -110,8 +110,97 @@ model Einstellung {
/// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile
/// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde.
schnellstartGepflegt Boolean @default(false)
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
/// 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())
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 {
@@ -161,6 +250,10 @@ model Benutzer {
/// So laesst sich ein Konto sperren, ohne auf den Ablauf des Cookies zu warten.
sitzungenUngueltigAb 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())
geaendertAm DateTime @default(now()) @updatedAt
@@ -174,6 +267,7 @@ model Benutzer {
schrittergebnisse Schrittergebnis[]
veroeffentlichteVersionen VorlagenVersion[] @relation("VersionVeroeffentlichtVon")
anhaenge Anhang[]
zustellungen Zustellung[]
@@index([aktiv])
@@index([rolle])
@@ -229,8 +323,9 @@ model Verzeichnisquelle {
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
mitarbeiter Mitarbeiter[]
zustellungen Zustellung[]
@@index([kundeId])
@@index([aktiv])
@@ -640,6 +735,8 @@ model Lauf {
anhaenge Anhang[]
/// Schluesselkandidat fuer den zusammengesetzten Fremdschluessel von Anhang.
zustellungen Zustellung[]
@@unique([id, kundeId])
@@index([kundeId, status])
@@index([geraetId, status])
+16 -4
View File
@@ -175,11 +175,23 @@ function feld(text, name) {
);
}
for (const [datei, block] of bloecke) {
if (!/docker compose -f docker-compose\.test\.yml up .*ldap/.test(block)) {
melde(
`${datei}: der Schranken-Block nennt den LDAP-Container nicht. ` +
`Ohne ihn ueberspringt die Suite den Verzeichnis-Abgleich, und der Lauf sieht gruen aus.`,
/*
* Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und
* sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll.
*/
for (const [dienst, was] of [
["ldap", "den Verzeichnis-Abgleich"],
["mailpit", "den echten Mailversand"],
]) {
const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test(
block,
);
if (!gefunden) {
melde(
`${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` +
`Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`,
);
}
}
}
}
+32
View File
@@ -1,6 +1,8 @@
import { Rolle } from "@/generated/prisma/enums";
import { Bell, BellOff } from "lucide-react";
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
import { meldungenSchalterAktion } from "@/server/actions/benachrichtigungen";
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -59,6 +61,36 @@ function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolea
</button>
</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 ? (
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
) : (
+1
View File
@@ -15,6 +15,7 @@ const REITER = [
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
{ pfad: "/admin/zustellungen", beschriftung: "Zustellungen" },
/*
* 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
+21 -2
View File
@@ -3,6 +3,8 @@ import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
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> = {
INTERN: "Intern",
@@ -17,8 +19,8 @@ const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
};
export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen();
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin");
const [einstellungen, meldungen] = await Promise.all([einstellungenHolen(), meldungseinstellungenHolen()]);
const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
@@ -68,6 +70,23 @@ export default async function EinstellungenSeite() {
/>
</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">
<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">
+195
View File
@@ -0,0 +1,195 @@
import Link from "next/link";
import { AlertTriangle, Check, Clock, RotateCcw } from "lucide-react";
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zustellungenHolen, zustellungenZaehlen } from "@/server/queries/zustellungen";
/**
* Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder Lauf funktioniert
* weiter, nur erfaehrt niemand davon.
*
* Gezeigt wird der **Betreff**, nicht der Rumpf: fuer „laeuft der Versand?“
* braucht es ihn nicht, und er traegt Lauftitel und Geraetenamen.
*/
const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
};
const ZUSTAND_TEXT: Record<Zustellzustand, string> = {
OFFEN: "offen",
GESENDET: "gesendet",
FEHLGESCHLAGEN: "wird wiederholt",
AUFGEGEBEN: "aufgegeben",
};
const ZUSTAND_FARBE: Record<Zustellzustand, string> = {
OFFEN: "text-nebentext",
GESENDET: "text-erledigt",
FEHLGESCHLAGEN: "text-faellig",
AUFGEGEBEN: "text-fehler",
};
function zeitpunkt(wert: Date): string {
return wert.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
}
export default async function ZustellungenSeite({
searchParams,
}: {
searchParams: Promise<{ zustand?: string }>;
}) {
// Jede Seite prüft selbst – die Hülle ist das zweite Netz (CLAUDE.md).
await sitzungErforderlich("verwaltung", "/admin/zustellungen");
const { zustand: roherZustand } = await searchParams;
const gefiltert =
roherZustand && roherZustand in Zustellzustand ? (roherZustand as Zustellzustand) : undefined;
const [zeilen, zaehler] = await Promise.all([
zustellungenHolen(gefiltert ? { zustand: gefiltert } : {}),
zustellungenZaehlen(),
]);
const aufgegeben = zaehler[Zustellzustand.AUFGEGEBEN];
return (
<>
<h1 className="text-2xl font-bold">Zustellungen</h1>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Was Laufzettel per Mail und Webhook hinausgeschickt hat. Eine fehlgeschlagene Zustellung wird
wiederholt — nach 1, 5, 30 und 120 Minuten — und bleibt nach dem fünften Fehlschlag als{" "}
<strong>aufgegeben</strong> stehen, damit es jemand sieht.
</p>
{aufgegeben > 0 ? (
<p
role="alert"
className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-fehler/40 bg-flaeche p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
<span>
<strong>{aufgegeben}</strong> {aufgegeben === 1 ? "Zustellung wurde" : "Zustellungen wurden"}{" "}
aufgegeben. Der Grund steht jeweils unter der Zeile; meist stimmt der SMTP-Zugang nicht mehr.
</span>
</p>
) : null}
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Nach Zustand filtern">
<Link
href="/admin/zustellungen"
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === undefined
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
Alle
</Link>
{Object.values(Zustellzustand).map((wert) => (
<Link
key={wert}
href={`/admin/zustellungen?zustand=${wert}`}
className={`flex min-h-11 items-center gap-2 rounded-pille border px-4 text-sm md:min-h-9 ${
gefiltert === wert
? "border-akzent bg-akzent-flaeche text-akzent"
: "border-linie text-nebentext hover:text-text"
}`}
>
{ZUSTAND_TEXT[wert]}
<span className="text-xs">{zaehler[wert]}</span>
</Link>
))}
</nav>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
{gefiltert === undefined
? "Noch nichts verschickt. Sobald ein Lauf jemandem zugewiesen, überfällig oder " +
"abgeschlossen wird, steht es hier."
: "In diesem Zustand steht gerade nichts."}
</p>
) : (
/* Am Handy nie waagerecht scrollen: dort Karten statt einer Tabelle. */
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className={`flex items-center gap-1.5 font-semibold ${ZUSTAND_FARBE[zeile.zustand]}`}>
{zeile.zustand === Zustellzustand.GESENDET ? (
<Check className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.AUFGEGEBEN ? (
<AlertTriangle className="size-4" strokeWidth={2} aria-hidden="true" />
) : zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<RotateCcw className="size-4" strokeWidth={2} aria-hidden="true" />
) : (
<Clock className="size-4" strokeWidth={2} aria-hidden="true" />
)}
{ZUSTAND_TEXT[zeile.zustand]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{ANLASS_TEXT[zeile.anlass]}
</span>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{zeile.weg === Zustellweg.MAIL ? "Mail" : "Webhook"}
</span>
{zeile.versuche > 1 ? (
<span className="text-xs text-nebentext">{zeile.versuche} Versuche</span>
) : null}
</div>
<p className="mt-2 break-words">{zeile.betreff}</p>
<p className="mt-1 break-all text-xs text-nebentext">{zeile.empfaenger}</p>
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
<div>
<dt className="inline">Eingereiht: </dt>
<dd className="inline">{zeitpunkt(zeile.erstelltAm)}</dd>
</div>
{zeile.gesendetAm ? (
<div>
<dt className="inline">Gesendet: </dt>
<dd className="inline">{zeitpunkt(zeile.gesendetAm)}</dd>
</div>
) : null}
{zeile.zustand === Zustellzustand.FEHLGESCHLAGEN ? (
<div>
<dt className="inline">Nächster Versuch: </dt>
<dd className="inline">{zeitpunkt(zeile.naechsterVersuchAm)}</dd>
</div>
) : null}
{zeile.laufId ? (
<div>
<Link href={`/laeufe/${zeile.laufId}`} className="underline hover:text-text">
Zum Lauf
</Link>
</div>
) : null}
</dl>
{zeile.letzterFehler ? (
<p className="mono mt-3 rounded-karte border border-linie bg-grund px-3 py-2 text-xs break-words text-fehler">
{zeile.letzterFehler}
</p>
) : null}
</li>
))}
</ul>
)}
</>
);
}
@@ -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>
);
}
+9 -1
View File
@@ -1,7 +1,8 @@
/**
* 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,
* 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");
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();
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Die Texte der Benachrichtigungen (#034).
*
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
* Meilensteins sitzt und einzeln pruefbar sein muss:
*
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
* Server, der nicht im Haus steht.
*
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
* aendern, und dann steht er vor diesem Kommentar.
*/
import { Meldeanlass } from "@/generated/prisma/enums";
/**
* Die drei Anlaesse, die zu einem Lauf gehoeren.
*
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
* waere derselbe Fehler erst im Betrieb aufgefallen.
*/
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
export type LaufKopf = {
readonly titel: string;
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
readonly geraet?: string | null;
readonly person?: string | null;
readonly faelligAm?: Date | null;
readonly vorlage?: string | null;
/** Wer zugewiesen oder abgeschlossen hat. */
readonly handelnder?: string | null;
};
export type Meldung = { readonly betreff: string; readonly rumpf: string };
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
function datum(wert: Date): string {
return wert.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
}
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
function kopfzeilen(kopf: LaufKopf): string[] {
const zeilen: string[] = [];
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
return zeilen;
}
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
}
/**
* Die Meldung zu einem Lauf.
*
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
* relativen Pfad ist eine Mail ohne Link.
*/
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
const zeilen = kopfzeilen(kopf);
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
switch (anlass) {
case Meldeanlass.LAUF_ZUGEWIESEN:
return {
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
: "Dir wurde ein Laufzettel zugewiesen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_UEBERFAELLIG:
return {
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.faelligAm
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
: "Dieser Laufzettel ist überfällig und noch offen.",
daten,
link,
]),
};
case Meldeanlass.LAUF_ABGESCHLOSSEN:
return {
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
rumpf: zusammensetzen([
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
: "Dieser Laufzettel wurde abgeschlossen.",
daten,
link,
]),
};
default: {
/*
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis.
*/
const unbekannt: never = anlass;
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
}
}
}
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
return {
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
rumpf: zusammensetzen([
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
`Grund: ${grund}`,
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
link,
]),
};
}
/**
* Der Rumpf eines Webhooks.
*
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
* ohne Auspacken filtern laesst.
*/
export function webhookRumpf(
anlass: Meldeanlass,
betreff: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
gesendetAm: Date,
): string {
return JSON.stringify({
anlass,
betreff,
link,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
zeitpunkt: gesendetAm.toISOString(),
});
}
+82
View File
@@ -0,0 +1,82 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Die Signatur eines Webhooks (#034).
*
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
* zeigen.
*
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
*
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
* wird deshalb `<zeitstempel>.<rumpf>`.
*/
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
export const TOLERANZ_SEKUNDEN = 300;
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
}
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
}
export type SignaturPruefung =
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
/**
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
*
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
* niemand nachgerechnet hat.
*/
export function signaturPruefen(
geheimnis: string,
rumpf: string,
kopf: string,
jetztSekunden: number,
toleranzSekunden = TOLERANZ_SEKUNDEN,
): SignaturPruefung {
const teile = new Map<string, string>();
for (const stueck of kopf.split(",")) {
const trenner = stueck.indexOf("=");
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
}
const roherStempel = teile.get("t");
const mitgeschickt = teile.get("v1");
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
const zeitstempel = Number.parseInt(roherStempel, 10);
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
/*
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
*/
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
return { gueltig: false, grund: "abgelaufen" };
}
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
/*
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
*/
const a = Buffer.from(erwartet, "utf8");
const b = Buffer.from(mitgeschickt, "utf8");
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
}
+145
View File
@@ -0,0 +1,145 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MeldungsFehler,
meldungseinstellungenSpeichern,
testmailSenden,
} from "@/server/services/benachrichtigungen";
import { prisma } from "@/lib/db";
/** Server Actions der Benachrichtigungen (#034). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
/**
* Ein leeres Feld heisst „loeschen“, ein fehlendes „unverändert lassen“.
*
* Beim Passwort ist das der Unterschied, auf den es ankommt: das Formular zeigt
* es nie an, also kommt es leer zurueck. Wuerde leer als „loeschen“ gelesen,
* loeschte jedes Speichern der uebrigen Felder das Passwort mit.
*/
const MeldungsSchema = z.object({
smtpHost: z.string().trim().optional(),
smtpPort: z.string().trim().optional(),
smtpTls: z.string().optional(),
smtpBenutzer: z.string().trim().optional(),
smtpPasswort: z.string().optional(),
smtpAbsender: z.string().trim().optional(),
webhookUrl: z.string().trim().optional(),
webhookGeheimnis: z.string().optional(),
});
export async function meldungenSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = MeldungsSchema.safeParse({
smtpHost: formular.get("smtpHost") ?? undefined,
smtpPort: formular.get("smtpPort") ?? undefined,
smtpTls: formular.get("smtpTls") ?? undefined,
smtpBenutzer: formular.get("smtpBenutzer") ?? undefined,
smtpPasswort: formular.get("smtpPasswort") ?? undefined,
smtpAbsender: formular.get("smtpAbsender") ?? undefined,
webhookUrl: formular.get("webhookUrl") ?? undefined,
webhookGeheimnis: formular.get("webhookGeheimnis") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const roherPort = eingabe.data.smtpPort ?? "";
let port: number | null = null;
if (roherPort !== "") {
const gelesen = Number.parseInt(roherPort, 10);
if (!Number.isFinite(gelesen)) return { fehler: "Der SMTP-Port muss eine Zahl sein." };
port = gelesen;
}
try {
await meldungseinstellungenSpeichern(
{
smtpHost: eingabe.data.smtpHost ?? null,
smtpPort: port,
smtpTls: eingabe.data.smtpTls === "an",
smtpBenutzer: eingabe.data.smtpBenutzer ?? null,
// Leer = unverändert. Das Formular zeigt ein gesetztes Passwort nie an.
...(eingabe.data.smtpPasswort ? { smtpPasswort: eingabe.data.smtpPasswort } : {}),
smtpAbsender: eingabe.data.smtpAbsender ?? null,
webhookUrl: eingabe.data.webhookUrl ?? null,
...(eingabe.data.webhookGeheimnis ? { webhookGeheimnis: eingabe.data.webhookGeheimnis } : {}),
},
benutzer.id,
);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
revalidatePath("/admin");
return { erfolg: "Gespeichert." };
}
export async function testmailAktion(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const an = z.string().trim().min(1).safeParse(formular.get("an"));
// Ohne Angabe an den Administrator selbst – das ist der häufigste Fall.
const ziel = an.success ? an.data : benutzer.email;
try {
await testmailSenden(ziel);
} catch (fehler) {
if (fehler instanceof MeldungsFehler) return { fehler: fehler.message };
throw fehler;
}
return { erfolg: `Testmail an ${ziel} ist hinausgegangen.` };
}
/**
* Benachrichtigungen je Person an- oder abschalten.
*
* Recht `verwaltung`, wie alles an einem Konto — es gibt kein eigenes Recht fuer
* Benutzer, die Rechtetabelle kennt `verwaltung` fuer ADMIN. **Fuer das eigene
* Konto fehlt damit noch die Selbstbedienung**: eine Profilseite gibt es bis
* Phase 2 nicht, sie kommt mit der Selbstauskunft (#035). Bis dahin stellt ein
* Administrator es um, und das steht so auch in der Anleitung.
*/
export async function meldungenSchalterAktion(formular: FormData): Promise<void> {
const { benutzer } = await rechtErforderlich("verwaltung");
/*
* Einstellig und ohne Rueckgabe, wie `aktivSetzenAktion` daneben: der Schalter
* sitzt in einem gewoehnlichen Formular in der Liste, nicht in einem
* `useActionState`. Eine zweistellige Action passt dort nicht hinein — und
* eine zweite Form derselben Sache waere Lärm.
*/
const eingabe = z
.object({ benutzerId: z.string().min(1), aktiv: z.enum(["an", "aus"]) })
.parse({ benutzerId: formular.get("benutzerId"), aktiv: formular.get("aktiv") });
const ziel = await prisma.benutzer.findUnique({
where: { id: eingabe.benutzerId },
select: { id: true, name: true, meldungenAktiv: true },
});
if (!ziel) return;
const neu = eingabe.aktiv === "an";
if (ziel.meldungenAktiv !== neu) {
await prisma.benutzer.update({ where: { id: ziel.id }, data: { meldungenAktiv: neu } });
await prisma.protokoll.create({
data: {
entitaet: "Benutzer",
entitaetId: ziel.id,
aktion: neu ? "Benachrichtigungen eingeschaltet" : "Benachrichtigungen abgeschaltet",
benutzerId: benutzer.id,
vorher: { meldungenAktiv: ziel.meldungenAktiv },
nachher: { meldungenAktiv: neu },
},
});
}
revalidatePath("/admin/benutzer");
}
+4
View File
@@ -9,6 +9,8 @@ export type BenutzerZeile = {
readonly name: string;
readonly rolle: Rolle;
readonly aktiv: boolean;
/** Bekommt diese Person Benachrichtigungen (#034)? */
readonly meldungenAktiv: boolean;
/** true, wenn das Konto ein Passwort hat, sich also lokal anmelden kann. */
readonly mitPasswort: boolean;
readonly ueberOidc: boolean;
@@ -24,6 +26,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: true,
rolle: true,
aktiv: true,
meldungenAktiv: true,
// Der Hash selbst verlaesst die Service-Schicht nie – nur die Auskunft,
// ob ueberhaupt einer da ist.
passwortHash: true,
@@ -38,6 +41,7 @@ export async function benutzerListe(): Promise<readonly BenutzerZeile[]> {
name: zeile.name,
rolle: zeile.rolle,
aktiv: zeile.aktiv,
meldungenAktiv: zeile.meldungenAktiv,
mitPasswort: zeile.passwortHash !== null,
ueberOidc: zeile.oidcSub !== null,
letzteAnmeldungAm: zeile.letzteAnmeldungAm,
+80
View File
@@ -0,0 +1,80 @@
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Die letzten Zustellungen – fuer Admin → Zustellungen (#034).
*
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder einzelne Lauf
* funktioniert weiter, nur erfaehrt niemand davon.
*
* Keine Mandantensicht: das Recht `verwaltung` hat nur, wer alles sehen darf,
* und eine Zustellung gehoert zum Betrieb der Anwendung, nicht zu einem Kunden.
* Der **Rumpf** wird trotzdem nicht ausgeliefert — er traegt Lauftitel und
* Geraetenamen, und fuer „laeuft der Versand?“ braucht es ihn nicht.
*/
export type ZustellZeile = {
readonly id: string;
readonly anlass: Meldeanlass;
readonly weg: Zustellweg;
readonly empfaenger: string;
readonly betreff: string;
readonly zustand: Zustellzustand;
readonly versuche: number;
readonly letzterFehler: string | null;
readonly naechsterVersuchAm: Date;
readonly gesendetAm: Date | null;
readonly erstelltAm: Date;
readonly laufId: string | null;
};
export const LISTE_HOECHSTENS = 200;
export type ZustellFilter = {
readonly zustand?: Zustellzustand;
};
export async function zustellungenHolen(filter: ZustellFilter = {}): Promise<readonly ZustellZeile[]> {
const zeilen = await prisma.zustellung.findMany({
where: filter.zustand ? { zustand: filter.zustand } : {},
orderBy: { erstelltAm: "desc" },
take: LISTE_HOECHSTENS,
select: {
id: true,
anlass: true,
weg: true,
empfaenger: true,
betreff: true,
zustand: true,
versuche: true,
letzterFehler: true,
naechsterVersuchAm: true,
gesendetAm: true,
erstelltAm: true,
laufId: true,
},
});
return zeilen;
}
export type ZustellZaehler = Readonly<Record<Zustellzustand, number>>;
/**
* Wie viele in welchem Zustand.
*
* Die Zahl der **aufgegebenen** ist die eine, die jemand sehen muss: alles
* andere regelt sich von selbst.
*/
export async function zustellungenZaehlen(): Promise<ZustellZaehler> {
const gruppen = await prisma.zustellung.groupBy({ by: ["zustand"], _count: { _all: true } });
const leer: Record<Zustellzustand, number> = {
[Zustellzustand.OFFEN]: 0,
[Zustellzustand.GESENDET]: 0,
[Zustellzustand.FEHLGESCHLAGEN]: 0,
[Zustellzustand.AUFGEGEBEN]: 0,
};
for (const gruppe of gruppen) leer[gruppe.zustand] = gruppe._count._all;
return leer;
}
+21 -54
View File
@@ -1,5 +1,3 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
@@ -10,6 +8,8 @@ import { eintraegeStroemen } from "@/server/ldap/leser";
import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
/**
* Der Abgleich (#016).
@@ -81,55 +81,6 @@ function sperrSchluessel(quelleId: string): string {
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.
*
@@ -218,9 +169,7 @@ export async function abgleichAusfuehren(
}
const quelle = await quelleZeileHolen(quelleId);
const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
lauf(quelle, benutzer),
);
const ergebnis = await mitSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () => lauf(quelle, benutzer));
if (ergebnis === undefined) {
throw new AbgleichLaeuftBereitsFehler(
`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,
);
}
}
}
+642
View File
@@ -0,0 +1,642 @@
import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
import { sichtbareKundenIds } from "@/server/mandant";
import { mitSperre } from "@/server/services/sperre";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Vier Anlaesse, nicht mehr: **zugewiesen**, **ueberfaellig**, **abgeschlossen**,
* **Abgleich fehlgeschlagen**. Ein allgemeiner Benachrichtigungsbaukasten ist
* eine Phase fuer sich; diese vier decken ab, was im Betrieb wirklich fehlt.
*
* Drei Entscheidungen tragen das Ganze:
*
* 1. **Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein haengender
* Mailserver darf kein Abhaken blockieren. Der Dienst schreibt eine Zeile in
* `Zustellung` — das ist eine Einfuegung in derselben Datenbank, die der
* Vorgang ohnehin anfasst —, und der Zeitplan schickt sie.
* 2. **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was
* **damals** war. Baute der Zeitplan den Text erst beim Senden, stuende in
* der Mail der Stand von jetzt.
* 3. **In keiner Meldung steht ein Schrittinhalt.** Darueber wacht
* `src/lib/meldungen.ts`; hier kommt nur hinzu, dass auch die Empfaengerwahl
* die Mandantengrenze achtet.
*/
export class MeldungsFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "MeldungsFehler";
}
}
/**
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
*
* Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
* Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
* Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
* wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
*/
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
/** Wie viele Zeilen ein Durchgang hoechstens anfasst. */
const BUENDEL = 50;
/**
* Wie lange die Sperre der Warteschlange gilt.
*
* Kurz, weil ein Durchgang kurz ist — und weil ein Absturz mitten im Senden die
* Warteschlange sonst fuer Stunden stillstellte.
*/
const SPERRE_MINUTEN = 10;
export const WARTESCHLANGE_SCHLUESSEL = "zustellung:warteschlange";
/* ------------------------------------------------------------------ */
/* Einstellungen */
/* ------------------------------------------------------------------ */
export type SmtpAngaben = {
readonly host: string | null;
readonly port: number | null;
readonly tls: boolean;
readonly benutzer: string | null;
readonly absender: string | null;
/**
* Nur, **ob** eines gesetzt ist — nie der Wert.
*
* Dieselbe Form wie beim LDAP-Bind-Passwort: ein Feld, das es nicht in eine
* Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
readonly passwortGesetzt: boolean;
};
export type WebhookAngaben = {
readonly url: string | null;
readonly geheimnisGesetzt: boolean;
};
export type MeldungsEinstellungen = {
readonly smtp: SmtpAngaben;
readonly webhook: WebhookAngaben;
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
readonly appUrl: string | null;
};
export async function meldungseinstellungenHolen(): Promise<MeldungsEinstellungen> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
return {
smtp: {
host: zeile?.smtpHost ?? null,
port: zeile?.smtpPort ?? null,
tls: zeile?.smtpTls ?? true,
benutzer: zeile?.smtpBenutzer ?? null,
absender: zeile?.smtpAbsender ?? null,
passwortGesetzt: Boolean(zeile?.smtpPasswortVerschluesselt),
},
webhook: {
url: zeile?.webhookUrl ?? null,
geheimnisGesetzt: Boolean(zeile?.webhookGeheimnisVerschluesselt),
},
appUrl: process.env["APP_URL"]?.trim() || null,
};
}
export type MeldungsAenderung = {
readonly smtpHost?: string | null;
readonly smtpPort?: number | null;
readonly smtpTls?: boolean;
readonly smtpBenutzer?: string | null;
/** `undefined` laesst es stehen, `null` loescht es, ein Text setzt es neu. */
readonly smtpPasswort?: string | null;
readonly smtpAbsender?: string | null;
readonly webhookUrl?: string | null;
readonly webhookGeheimnis?: string | null;
};
function geputzt(wert: string | null | undefined): string | null | undefined {
if (wert === undefined) return undefined;
if (wert === null) return null;
return wert.trim() || null;
}
export async function meldungseinstellungenSpeichern(
aenderung: MeldungsAenderung,
durchBenutzerId: string,
): Promise<void> {
const host = geputzt(aenderung.smtpHost);
if (host && aenderung.smtpPort === null) {
throw new MeldungsFehler("Zu einem SMTP-Host gehört ein Port.");
}
if (aenderung.smtpPort !== undefined && aenderung.smtpPort !== null) {
if (!Number.isInteger(aenderung.smtpPort) || aenderung.smtpPort < 1 || aenderung.smtpPort > 65535) {
throw new MeldungsFehler("Der SMTP-Port muss zwischen 1 und 65535 liegen.");
}
}
const url = geputzt(aenderung.webhookUrl);
if (url) {
let geprueft: URL;
try {
geprueft = new URL(url);
} catch {
throw new MeldungsFehler("Die Webhook-Adresse ist keine gültige URL.");
}
if (geprueft.protocol !== "https:" && geprueft.protocol !== "http:") {
throw new MeldungsFehler("Die Webhook-Adresse muss mit http:// oder https:// beginnen.");
}
}
const vorher = await meldungseinstellungenHolen();
const passwort = geputzt(aenderung.smtpPasswort);
const geheimnis = geputzt(aenderung.webhookGeheimnis);
const daten = {
...(host !== undefined ? { smtpHost: host } : {}),
...(aenderung.smtpPort !== undefined ? { smtpPort: aenderung.smtpPort } : {}),
...(aenderung.smtpTls !== undefined ? { smtpTls: aenderung.smtpTls } : {}),
...(geputzt(aenderung.smtpBenutzer) !== undefined
? { smtpBenutzer: geputzt(aenderung.smtpBenutzer) }
: {}),
...(geputzt(aenderung.smtpAbsender) !== undefined
? { smtpAbsender: geputzt(aenderung.smtpAbsender) }
: {}),
...(passwort !== undefined
? { smtpPasswortVerschluesselt: passwort === null ? null : verschluesseln(passwort) }
: {}),
...(url !== undefined ? { webhookUrl: url } : {}),
...(geheimnis !== undefined
? { webhookGeheimnisVerschluesselt: geheimnis === null ? null : verschluesseln(geheimnis) }
: {}),
};
if (Object.keys(daten).length === 0) return;
await prisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", ...daten },
update: daten,
});
/*
* Ins Protokoll gehen **Namen, nicht Werte**. Ein Protokolleintrag mit dem
* SMTP-Passwort waere genau die Stelle, an der die Verschluesselung sinnlos
* wird — und das Protokoll ist ansehbar (#023).
*/
await prisma.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Benachrichtigungen geändert",
benutzerId: durchBenutzerId,
vorher: {
smtpHost: vorher.smtp.host,
smtpPort: vorher.smtp.port,
smtpTls: vorher.smtp.tls,
smtpBenutzer: vorher.smtp.benutzer,
smtpAbsender: vorher.smtp.absender,
smtpPasswortGesetzt: vorher.smtp.passwortGesetzt,
webhookUrl: vorher.webhook.url,
webhookGeheimnisGesetzt: vorher.webhook.geheimnisGesetzt,
},
nachher: { geaenderteFelder: Object.keys(daten) },
},
});
}
/* ------------------------------------------------------------------ */
/* Versand */
/* ------------------------------------------------------------------ */
type SmtpZugang = {
readonly host: string;
readonly port: number;
readonly tls: boolean;
readonly benutzer: string | null;
readonly passwort: string | null;
readonly absender: string;
};
/** Der SMTP-Zugang aus der Datenbank, entschluesselt. `undefined` = nicht eingerichtet. */
async function smtpZugang(): Promise<SmtpZugang | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.smtpHost || !zeile.smtpPort) return undefined;
return {
host: zeile.smtpHost,
port: zeile.smtpPort,
tls: zeile.smtpTls,
benutzer: zeile.smtpBenutzer,
passwort: zeile.smtpPasswortVerschluesselt ? entschluesseln(zeile.smtpPasswortVerschluesselt) : null,
// Ohne eigenen Absender der Benutzername, sonst ein neutraler Name am Host.
absender: zeile.smtpAbsender ?? zeile.smtpBenutzer ?? `laufzettel@${zeile.smtpHost}`,
};
}
async function webhookZugang(): Promise<{ url: string; geheimnis: string | null } | undefined> {
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
if (!zeile?.webhookUrl) return undefined;
return {
url: zeile.webhookUrl,
geheimnis: zeile.webhookGeheimnisVerschluesselt
? entschluesseln(zeile.webhookGeheimnisVerschluesselt)
: null,
};
}
/**
* Eine Mail verschicken.
*
* `secure` nur auf 465 — das ist der Port fuer implizites TLS. Auf 587 und 25
* wird mit `requireTLS` auf StartTLS bestanden, sofern TLS verlangt ist. Ein
* `secure: true` auf 587 laeuft in einen Zeitablauf, und die Meldung dazu
* („greeting never received“) sagt nichts ueber die Ursache.
*/
async function mailSenden(zugang: SmtpZugang, an: string, betreff: string, text: string): Promise<void> {
const transport = createTransport({
host: zugang.host,
port: zugang.port,
secure: zugang.port === 465,
requireTLS: zugang.tls && zugang.port !== 465,
ignoreTLS: !zugang.tls,
...(zugang.benutzer ? { auth: { user: zugang.benutzer, pass: zugang.passwort ?? "" } } : {}),
// Ein haengender Mailserver darf den Durchgang nicht aufhalten.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
try {
await transport.sendMail({ from: zugang.absender, to: an, subject: betreff, text });
} finally {
transport.close();
}
}
/** Einen Webhook abschicken – mit Signatur und Zeitstempel. */
async function webhookSenden(url: string, geheimnis: string | null, rumpf: string): Promise<void> {
const sekunden = Math.floor(Date.now() / 1000);
const abbruch = AbortSignal.timeout(15_000);
const antwort = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(geheimnis ? { [SIGNATUR_KOPF]: signaturBauen(geheimnis, rumpf, sekunden) } : {}),
},
body: rumpf,
signal: abbruch,
});
if (!antwort.ok) {
throw new Error(`Die Gegenseite antwortete mit ${antwort.status} ${antwort.statusText}`);
}
}
/**
* Eine Testmail, direkt und ohne Warteschlange.
*
* Mit Warteschlange waere der Knopf „Testmail schicken“ wertlos: er wuerde
* „eingereiht“ melden, und ob es klappt, saehe man eine Minute spaeter
* woanders. Hier soll der Fehler **sofort** und im Klartext erscheinen.
*/
export async function testmailSenden(an: string): Promise<void> {
const zugang = await smtpZugang();
if (!zugang) throw new MeldungsFehler("Es ist kein SMTP-Server eingerichtet.");
const ziel = an.trim();
if (!ziel.includes("@")) throw new MeldungsFehler("Das ist keine Mailadresse.");
try {
await mailSenden(
zugang,
ziel,
"Laufzettel: Testmail",
"Wenn diese Nachricht angekommen ist, kann Laufzettel Mail verschicken.\n\n" +
`Server: ${zugang.host}:${zugang.port}${zugang.tls ? " mit TLS" : " ohne TLS"}\n` +
`Absender: ${zugang.absender}`,
);
} catch (fehler) {
throw new MeldungsFehler(
`Die Testmail ging nicht hinaus: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
}
/* ------------------------------------------------------------------ */
/* Einreihen */
/* ------------------------------------------------------------------ */
function appUrl(): string {
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
}
function laufLink(laufId: string): string {
return `${appUrl()}/laeufe/${laufId}`;
}
/**
* Die Empfaenger einer Lauf-Meldung.
*
* Wer zugewiesen ist, und bei einem Abschluss zusaetzlich, wer den Lauf angelegt
* hat. **Nie der Handelnde selbst** — eine Mail darueber, was man gerade eben
* geklickt hat, ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten.
*
* Und: **die Mandantengrenze gilt auch hier.** Ein Benutzer, der den Kunden des
* Laufs nicht sehen darf, bekommt keine Mail mit dessen Lauftitel und Geraet.
* Ohne diese Pruefung waere die Benachrichtigung der eine Weg nach draussen, den
* `sichtbareKundenIds` nicht abdeckt.
*/
async function laufEmpfaenger(
lauf: {
readonly id: string;
readonly kundeId: string;
readonly zugewiesenAnId: string | null;
readonly erstelltVonId: string | null;
},
anlass: LaufAnlass,
handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>();
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId);
}
if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return [];
const kandidaten = await prisma.benutzer.findMany({
where: { id: { in: [...kennungen] }, aktiv: true, meldungenAktiv: true },
select: { id: true, email: true, name: true, rolle: true },
});
const erlaubt: { id: string; email: string; name: string }[] = [];
for (const person of kandidaten) {
const sichtbar = await sichtbareKundenIds({ id: person.id, rolle: person.rolle });
if (sichtbar === undefined || sichtbar.includes(lauf.kundeId)) {
erlaubt.push({ id: person.id, email: person.email, name: person.name });
}
}
return erlaubt;
}
/** Eine Zeile je Empfaenger, und eine fuer den Webhook. */
async function einreihen(
anlass: Meldeanlass,
betreff: string,
rumpf: string,
link: string,
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
empfaenger: readonly { readonly id: string; readonly email: string }[],
): Promise<number> {
// Ausdruecklich typisiert: aus dem Literal allein leitete TypeScript
// `weg: "MAIL"` und `benutzerId: string` ab, und die Webhook-Zeile darunter
// passte dann nicht mehr hinein.
const zeilen: Prisma.ZustellungCreateManyInput[] = empfaenger.map((person) => ({
anlass,
weg: Zustellweg.MAIL,
empfaenger: person.email,
betreff,
rumpf,
benutzerId: person.id,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
}));
const webhook = await webhookZugang();
if (webhook) {
zeilen.push({
anlass,
weg: Zustellweg.WEBHOOK,
empfaenger: webhook.url,
betreff,
rumpf: webhookRumpf(anlass, betreff, link, bezug, new Date()),
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
benutzerId: null,
laufId: bezug.laufId ?? null,
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
});
}
if (zeilen.length === 0) return 0;
/*
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
*/
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
return ergebnis.count;
}
/** Meldung zu einem Lauf einreihen. Gibt die Zahl der erzeugten Zeilen zurueck. */
export async function laufMeldungEinreihen(
anlass: LaufAnlass,
laufId: string,
handelnderId: string | null = null,
): Promise<number> {
const lauf = await prisma.lauf.findUnique({
where: { id: laufId },
select: {
id: true,
kundeId: true,
titel: true,
faelligAm: true,
zugewiesenAnId: true,
erstelltVonId: true,
mitarbeiterName: true,
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
},
});
if (!lauf) return 0;
const empfaenger = await laufEmpfaenger(lauf, anlass, handelnderId);
const webhook = await webhookZugang();
// Niemand da und kein Webhook: dann auch keine Zeile, die nie ankommt.
if (empfaenger.length === 0 && !webhook) return 0;
const handelnder = handelnderId
? await prisma.benutzer.findUnique({ where: { id: handelnderId }, select: { name: true } })
: null;
const { betreff, rumpf } = laufMeldung(
anlass,
{
titel: lauf.titel,
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
person: lauf.mitarbeiter?.anzeigename ?? lauf.mitarbeiterName,
faelligAm: lauf.faelligAm,
vorlage: lauf.vorlagenVersion.vorlage.name,
handelnder: handelnder?.name ?? null,
},
laufLink(lauf.id),
);
return einreihen(anlass, betreff, rumpf, laufLink(lauf.id), { laufId: lauf.id }, empfaenger);
}
/**
* Meldung zu einem fehlgeschlagenen Abgleich einreihen.
*
* Empfaenger sind **alle aktiven Administratoren**: eine Verzeichnisquelle
* gehoert niemandem persoenlich, und wer sie eingerichtet hat, ist vielleicht
* nicht mehr im Haus.
*/
export async function abgleichMeldungEinreihen(quelleId: string, grund: string): Promise<number> {
const quelle = await prisma.verzeichnisquelle.findUnique({
where: { id: quelleId },
select: { id: true, name: true },
});
if (!quelle) return 0;
const admins = await prisma.benutzer.findMany({
where: { rolle: "ADMIN", aktiv: true, meldungenAktiv: true },
select: { id: true, email: true },
});
const webhook = await webhookZugang();
if (admins.length === 0 && !webhook) return 0;
const link = `${appUrl()}/verwaltung/verzeichnis`;
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
return einreihen(
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
betreff,
rumpf,
link,
{ verzeichnisquelleId: quelle.id },
admins,
);
}
/**
* Ueberfaellige Laeufe einreihen – fuer den Zeitplan.
*
* Faellig **und** nicht vom Tisch. Der eindeutige Index sorgt dafuer, dass das
* je Lauf und Empfaenger genau einmal passiert, auch wenn der Zeitplan jede
* Minute nachsieht.
*/
export async function ueberfaelligeEinreihen(jetzt = new Date()): Promise<number> {
const laeufe = await prisma.lauf.findMany({
where: {
faelligAm: { lt: jetzt },
status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] },
zugewiesenAnId: { not: null },
},
select: { id: true },
});
let erzeugt = 0;
for (const lauf of laeufe) {
erzeugt += await laufMeldungEinreihen(Meldeanlass.LAUF_UEBERFAELLIG, lauf.id);
}
return erzeugt;
}
/* ------------------------------------------------------------------ */
/* Abarbeiten */
/* ------------------------------------------------------------------ */
export type DurchgangErgebnis = {
readonly gesendet: number;
readonly fehlgeschlagen: number;
readonly aufgegeben: number;
};
/**
* Was nach einem Fehlschlag mit der Zeile passiert.
*
* `versucheGesamt` zaehlt **einschliesslich** des gerade gescheiterten: nach dem
* ersten Fehlschlag wird eine Minute gewartet, nach dem zweiten fuenf, dann
* dreissig, dann zwei Stunden — und nach dem fuenften ist Schluss. Vier
* Wartezeiten ergeben also fuenf Versuche.
*
* Eine reine Funktion, damit sich die Wiederholungsregel pruefen laesst, ohne
* einen Mailserver zu brauchen oder zwei Stunden zu warten.
*/
export function naechsterVersuch(
versucheGesamt: number,
jetzt: Date,
): { readonly zustand: Zustellzustand; readonly naechsterVersuchAm: Date } {
const wartezeit = WARTEZEITEN_MINUTEN[versucheGesamt - 1];
if (wartezeit === undefined) {
/*
* Aufgegeben — aber **sichtbar**. Eine stille Warteschlange ist schlimmer
* als keine: niemand wuerde merken, dass seit Wochen keine Mail mehr
* hinausgeht. Der Zeitpunkt bleibt stehen, damit die Liste die Reihenfolge
* behaelt.
*/
return { zustand: Zustellzustand.AUFGEGEBEN, naechsterVersuchAm: jetzt };
}
return {
zustand: Zustellzustand.FEHLGESCHLAGEN,
naechsterVersuchAm: new Date(jetzt.getTime() + wartezeit * 60_000),
};
}
/**
* Einen Durchgang der Warteschlange fahren.
*
* `undefined`, wenn ein anderer Durchgang laeuft — dieselbe Sperre wie beim
* Abgleich, in der Datenbank und damit auch ueber zwei Instanzen hinweg. Ohne
* sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
export async function warteschlangeAbarbeiten(jetzt = new Date()): Promise<DurchgangErgebnis | undefined> {
return mitSperre(WARTESCHLANGE_SCHLUESSEL, SPERRE_MINUTEN, async () => {
const offene = await prisma.zustellung.findMany({
where: {
zustand: { in: [Zustellzustand.OFFEN, Zustellzustand.FEHLGESCHLAGEN] },
naechsterVersuchAm: { lte: jetzt },
},
orderBy: { naechsterVersuchAm: "asc" },
take: BUENDEL,
});
let gesendet = 0;
let fehlgeschlagen = 0;
let aufgegeben = 0;
// Die Zugaenge einmal je Durchgang, nicht je Zeile.
const smtp = await smtpZugang();
const webhook = await webhookZugang();
for (const zeile of offene) {
try {
if (zeile.weg === Zustellweg.MAIL) {
if (!smtp) throw new Error("Es ist kein SMTP-Server eingerichtet.");
await mailSenden(smtp, zeile.empfaenger, zeile.betreff, zeile.rumpf);
} else {
if (!webhook) throw new Error("Es ist kein Webhook eingerichtet.");
await webhookSenden(webhook.url, webhook.geheimnis, zeile.rumpf);
}
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: Zustellzustand.GESENDET,
gesendetAm: new Date(),
versuche: zeile.versuche + 1,
letzterFehler: null,
},
});
gesendet += 1;
} catch (fehler) {
const naechster = naechsterVersuch(zeile.versuche + 1, jetzt);
await prisma.zustellung.update({
where: { id: zeile.id },
data: {
zustand: naechster.zustand,
naechsterVersuchAm: naechster.naechsterVersuchAm,
versuche: zeile.versuche + 1,
// Gekappt: eine TLS-Fehlermeldung kann Kilobyte lang sein.
letzterFehler: (fehler instanceof Error ? fehler.message : "unbekannter Fehler").slice(0, 500),
},
});
if (naechster.zustand === Zustellzustand.AUFGEGEBEN) aufgegeben += 1;
else fehlgeschlagen += 1;
}
}
return { gesendet, fehlgeschlagen, aufgegeben };
});
}
+58 -8
View File
@@ -1,7 +1,9 @@
import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
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 {
MandantFehler,
@@ -455,6 +457,30 @@ function variablenPruefen(
* Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht
* 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> {
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId);
@@ -494,7 +520,7 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
.flatMap((abschnitt) => abschnitt.schrittIds),
);
return prisma.$transaction(async (tx) => {
const angelegt = await prisma.$transaction(async (tx) => {
const lauf = await tx.lauf.create({
data: {
vorlagenVersionId: version.id,
@@ -548,6 +574,12 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
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.
*/
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);
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return lauf;
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
const offen = await offenePflichtschritte(tx, laufId);
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() },
},
});
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,
benutzer: HandelnderBenutzer,
): Promise<Lauf> {
return prisma.$transaction(async (tx) => {
const geaendertesErgebnis = await prisma.$transaction(async (tx) => {
const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer);
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 });
await tx.protokoll.create({
@@ -1034,8 +1075,17 @@ export async function laufKopfdatenAendern(
nachher: kopfAuszug(nachher, geaendert),
},
});
return nachher;
return {
lauf: nachher,
// Nur eine **neue** Zuweisung an eine Person meldet; ein Entzug nicht.
neuZugewiesenAn: geaendert.includes("Zuweisung") ? nachher.zugewiesenAnId : null,
};
});
if (geaendertesErgebnis.neuZugewiesenAn) {
await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, laufId, benutzer.id);
}
return geaendertesErgebnis.lauf;
}
/**
+66
View File
@@ -0,0 +1,66 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Eine Sperre in der Datenbank, ueber Prozesse und Instanzen hinweg (#016, #034).
*
* Erst war sie Teil des Abgleichs; mit der Zustell-Warteschlange braucht sie ein
* zweiter Aufrufer, und zwei Fassungen derselben Mechanik waere die schlechteste
* aller Loesungen. Die Tabelle heisst weiter `AbgleichSperre` — ein Umbenennen
* waere eine Migration, die nichts verbessert, und der Schluessel sagt ohnehin,
* wer gerade sperrt (`abgleich:<quelleId>`, `zustellung:warteschlange`).
*
* **In der Datenbank und nicht im Prozess**, weil hinter einem Lastverteiler
* zwei Instanzen laufen koennen. Ein `let laeuft = false` im Modul wuesste nichts
* von der zweiten.
*/
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Durchgaenge im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Durchgang, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
+83
View File
@@ -0,0 +1,83 @@
import cron, { type ScheduledTask } from "node-cron";
import { ueberfaelligeEinreihen, warteschlangeAbarbeiten } from "@/server/services/benachrichtigungen";
/**
* Der Zeitplan der Benachrichtigungen (#034).
*
* **Im selben Prozess** wie die Anwendung und wie der Abgleich-Zeitplan — kein
* zweiter Dienst, aus demselben Grund wie dort: beide Betriebswege dieses
* Projekts starten genau einen Prozess, und eine zweite Einheit waere eine
* zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL` hinterlegen muss.
*
* Ein eigener Zeitplan und nicht derselbe wie der Abgleich: der eine darf nicht
* warten, weil der andere gerade ein grosses Verzeichnis liest. Zwei Takte sind
* billig, ein verzoegerter Mailversand nicht.
*
* Drei Dinge halten das zusammen:
*
* 1. **Die Sperre setzt der Dienst, nicht der Zeitplan.**
* `warteschlangeAbarbeiten` nimmt sie selbst — in der Datenbank und damit
* auch ueber zwei Instanzen hinweg. Eine zweite Sperre hier waere schlimmer
* als keine: sie truege einen anderen Schluessel und liefe an der ersten
* vorbei.
* 2. **Ein Fehler beendet den Zeitplan nicht.** Ein Mailserver, der gerade nicht
* antwortet, ist der Normalfall im Betrieb; was passiert ist, steht an der
* Zustellung.
* 3. **Ueberfaelligkeit wird jede Minute geprueft, aber je Lauf nur einmal
* gemeldet.** Dafuer sorgt der partielle eindeutige Index
* `Zustellung_ueberfaellig_einmal` — ohne ihn bekaeme der Zustaendige fuer
* einen Lauf, der ueber das Wochenende steht, 2880 Mails.
*/
/** Jede Minute. Was wirklich faellig ist, entscheidet die Datenbank. */
const TAKT = "* * * * *";
let plan: ScheduledTask | undefined;
/**
* Ein Durchgang: ueberfaellige einreihen, dann die Warteschlange abarbeiten.
*
* In dieser Reihenfolge, damit eine gerade erkannte Ueberfaelligkeit nicht erst
* eine Minute spaeter hinausgeht. Exportiert, damit ein Test den Durchgang
* aufrufen kann, ohne eine Minute zu warten.
*/
export async function meldungsDurchgang(): Promise<void> {
try {
await ueberfaelligeEinreihen();
} catch (fehler) {
console.error(
"Überfällige Läufe konnten nicht eingereiht werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
try {
/*
* `undefined` heisst: eine andere Instanz ist dran. Kein Fehler und keine
* Zeile im Protokoll — im Normalbetrieb mit zwei Instanzen waere das jede
* Minute eine.
*/
await warteschlangeAbarbeiten();
} catch (fehler) {
console.error(
"Die Zustell-Warteschlange konnte nicht abgearbeitet werden:",
fehler instanceof Error ? fehler.message : fehler,
);
}
}
export function meldungsplanStarten(): void {
if (plan) return;
plan = cron.schedule(TAKT, () => {
void meldungsDurchgang();
});
}
export function meldungsplanAnhalten(): void {
plan?.stop();
plan = undefined;
}
export function meldungsplanLaeuft(): boolean {
return plan !== undefined;
}
+13 -16
View File
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
AbgleichLaeuftBereitsFehler,
abgleichAusfuehren,
faelligeQuellen,
mitAbgleichSperre,
} from "@/server/services/abgleich";
import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
// Seit #034 liegt die Sperre in ihrem eigenen Modul: die Zustell-Warteschlange
// braucht dieselbe Mechanik, und zwei Fassungen davon waeren die schlechteste
// aller Loesungen.
import { mitSperre } from "@/server/services/sperre";
import type { HandelnderBenutzer } from "@/server/mandant";
import { quelleAnlegen, quelleBearbeiten, VerzeichnisFehler } from "@/server/services/verzeichnis";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -99,14 +98,14 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal();
const erster = mitAbgleichSperre("abgleich:test", 5, async () => {
const erster = mitSperre("abgleich:test", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "erster";
});
await ersterIstDrin;
const zweiter = await mitAbgleichSperre("abgleich:test", 5, async () => "zweiter");
const zweiter = await mitSperre("abgleich:test", 5, async () => "zweiter");
freigeben();
// Abgewiesen, nicht eingereiht: `undefined` und nicht ein zweites Ergebnis.
@@ -114,26 +113,26 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
expect(await erster).toBe("erster");
// Danach ist die Sperre wieder frei.
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 () => {
const [haeltDieSperre, ersterIstDrin] = signal();
const [freigeben, darfWeiter] = signal();
const eine = mitAbgleichSperre("abgleich:eine", 5, async () => {
const eine = mitSperre("abgleich:eine", 5, async () => {
haeltDieSperre();
await darfWeiter;
return "eine";
});
await ersterIstDrin;
expect(await mitAbgleichSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
expect(await mitSperre("abgleich:andere", 5, async () => "andere")).toBe("andere");
freigeben();
expect(await eine).toBe("eine");
});
it("gibt die Sperre auch frei, wenn der Lauf wirft", async () => {
await expect(
mitAbgleichSperre("abgleich:test", 5, async () => {
mitSperre("abgleich:test", 5, async () => {
throw new Error("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),
},
});
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);
});
@@ -163,9 +162,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich-Sperre", () => {
laeuftAbAm: new Date(Date.now() + 60 * 60 * 1000),
},
});
expect(
await mitAbgleichSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen"),
).toBeUndefined();
expect(await mitSperre("abgleich:test", 5, async () => "haette nicht laufen duerfen")).toBeUndefined();
// Und sie wird auch nicht beim Aufraeumen mitgeloescht: sie gehoert einem
// anderen Lauf.
const zeile = await testPrisma.abgleichSperre.findUniqueOrThrow({
+960
View File
@@ -0,0 +1,960 @@
import { createServer, type Server } from "node:http";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Meldeanlass,
VersionStatus,
Zustellweg,
Zustellzustand,
} from "@/generated/prisma/enums";
import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur";
import { verschluesseln } from "@/lib/geheimnisse";
import {
WARTEZEITEN_MINUTEN,
abgleichMeldungEinreihen,
laufMeldungEinreihen,
meldungseinstellungenHolen,
meldungseinstellungenSpeichern,
naechsterVersuch,
ueberfaelligeEinreihen,
warteschlangeAbarbeiten,
} from "@/server/services/benachrichtigungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Benachrichtigungen per Mail und Webhook (#034).
*
* Fuenf Zusagen tragen das Ganze, und jede hat hier ihren Test:
*
* 1. Die vier Anlaesse gehen an die richtigen Empfaenger — **gegen einen echten
* SMTP-Faenger**, nicht gegen eine abgefangene Funktion.
* 2. In **keiner** Nachricht steht ein Schrittinhalt und damit kein erzeugtes
* Passwort.
* 3. Eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar.
* 4. Zwei gleichzeitige Durchgaenge schicken nichts zweimal.
* 5. Wer Benachrichtigungen abgeschaltet hat, bekommt nichts.
*/
const MAILPIT = "http://127.0.0.1:8025";
const SMTP_PORT = 1025;
/**
* Steht der Faenger?
*
* **Auf Modulebene geprueft, nicht in `beforeAll`.** `it.runIf(...)` wird beim
* Einlesen der Datei ausgewertet — ein in `beforeAll` gesetztes Flag kaeme zu
* spaet, und alle Mailtests waeren stillschweigend uebersprungen. Genau so ist
* es beim ersten Lauf passiert: 3 skipped, gruener Durchlauf, nichts geprueft.
*/
async function mailpitAntwortet(): Promise<boolean> {
try {
const antwort = await fetch(`${MAILPIT}/api/v1/info`, { signal: AbortSignal.timeout(2_000) });
return antwort.ok;
} catch {
return false;
}
}
const mailpitDa = await mailpitAntwortet();
if (!mailpitDa && process.env["CI"]) {
throw new Error(
"Mailpit antwortet nicht auf 8025. In CI duerfen die Mailtests nicht uebersprungen werden — " +
"`docker compose -f docker-compose.test.yml up -d --wait mailpit`.",
);
}
if (!mailpitDa) {
console.warn(
"Mailpit laeuft nicht; die Mailtests werden uebersprungen. " +
"Start: docker compose -f docker-compose.test.yml up -d --wait mailpit",
);
}
type MailpitNachricht = {
readonly ID: string;
readonly Subject: string;
readonly To: readonly { readonly Address: string }[];
};
async function mailpitLeeren(): Promise<void> {
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
}
async function mailpitNachrichten(): Promise<MailpitNachricht[]> {
const antwort = await fetch(`${MAILPIT}/api/v1/messages?limit=100`);
const daten = (await antwort.json()) as { messages?: MailpitNachricht[] };
return daten.messages ?? [];
}
/** Der Klartext einer Nachricht – fuer die Passwort-Pruefung. */
async function mailpitText(id: string): Promise<string> {
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
const daten = (await antwort.json()) as { Text?: string; Subject?: string };
return `${daten.Subject ?? ""}\n${daten.Text ?? ""}`;
}
/* ------------------------------------------------------------------ */
/* Die reinen Teile */
/* ------------------------------------------------------------------ */
describe("Die Signatur eines Webhooks", () => {
const GEHEIMNIS = "sehr-geheim";
const RUMPF = '{"anlass":"LAUF_ZUGEWIESEN"}';
const JETZT = 1_762_334_400;
it("lässt sich mit dem Geheimnis nachrechnen", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(kopf).toMatch(/^t=1762334400,v1=[0-9a-f]{64}$/);
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: true });
});
it("mit einem falschen Geheimnis nicht", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen("falsch", RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "falsch",
});
});
it("und auch nicht, wenn der Rumpf nachträglich geändert wurde", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
expect(signaturPruefen(GEHEIMNIS, '{"anlass":"ETWAS_ANDERES"}', kopf, JETZT).gueltig).toBe(false);
});
it("weist eine zu alte und eine zu junge Nachricht ab", () => {
const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
// Zu alt: eine abgefangene Nachricht soll sich nicht morgen einspielen lassen.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
/*
* Und zu jung. Nur „zu alt“ abzufangen liesse eine Nachricht mit Zeitstempel
* im Jahr 2040 fuer immer gueltig sein.
*/
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT - 301)).toEqual({
gueltig: false,
grund: "abgelaufen",
});
// Innerhalb der Toleranz geht beides.
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 299).gueltig).toBe(true);
});
it("weist einen kaputten Kopf ab, statt zu werfen", () => {
for (const kopf of ["", "unsinn", "t=abc,v1=ff", "v1=ff", "t=1762334400"]) {
expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({
gueltig: false,
grund: "form",
});
}
});
it("signiert den Zeitstempel mit", () => {
/*
* Stuende er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu
* brechen. Dieselbe Signatur mit einem anderen `t` muss also scheitern.
*/
const echt = signaturBauen(GEHEIMNIS, RUMPF, JETZT);
const geschoben = echt.replace(`t=${JETZT}`, `t=${JETZT + 10}`);
expect(signaturPruefen(GEHEIMNIS, RUMPF, geschoben, JETZT + 10)).toEqual({
gueltig: false,
grund: "falsch",
});
});
});
describe("Die Texte der Meldungen", () => {
const KOPF = {
titel: "PC Neuinstallation – PC-001",
geraet: "PC-001",
person: "Maria Huber",
faelligAm: new Date("2026-10-07T10:00:00Z"),
vorlage: "PC Neuinstallation",
handelnder: "Petra Klein",
};
it("nennt den Anlass im Betreff", () => {
expect(laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").betreff).toContain("ist dir zugewiesen");
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").betreff).toContain("überfällig");
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").betreff).toContain("abgeschlossen");
expect(abgleichMeldung("AD Zentrale", "Verbindung abgelehnt", "u").betreff).toContain("fehlgeschlagen");
});
it("schreibt das Fälligkeitsdatum deutsch", () => {
expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf).toContain("07.10.2026");
});
it("lässt leere Felder weg, statt „null“ zu schreiben", () => {
const dürftig = laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, { titel: "Nur ein Titel" }, "u");
expect(dürftig.rumpf).not.toContain("null");
expect(dürftig.rumpf).not.toContain("Gerät:");
expect(dürftig.rumpf).not.toContain("undefined");
});
it("trägt den Link, damit die Mail zu etwas führt", () => {
const link = "https://laufzettel.firma.de/laeufe/abc";
expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, link).rumpf).toContain(link);
expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link);
});
it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => {
/*
* Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber
* die **Form**: `laufMeldung` nimmt nur Kopfdaten. Dass auch nichts aus der
* Datenbank durchsickert, prueft der Test „in keiner Nachricht steht ein
* erzeugtes Passwort“ weiter unten — ueber alle Rumpftexte, wie in #012.
*/
const alle = [
laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf,
laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").rumpf,
abgleichMeldung("AD", "Grund", "u").rumpf,
].join("\n");
expect(alle).not.toContain("{{");
expect(alle).not.toContain("Password");
expect(alle).not.toContain("New-ADUser");
});
it("baut einen Webhook-Rumpf mit dem Anlass als Feld", () => {
const rumpf = webhookRumpf(
Meldeanlass.LAUF_ABGESCHLOSSEN,
"Betreff",
"https://x/laeufe/1",
{ laufId: "l1" },
new Date("2026-10-05T08:00:00Z"),
);
expect(JSON.parse(rumpf)).toEqual({
anlass: "LAUF_ABGESCHLOSSEN",
betreff: "Betreff",
link: "https://x/laeufe/1",
laufId: "l1",
verzeichnisquelleId: null,
zeitpunkt: "2026-10-05T08:00:00.000Z",
});
});
});
describe("Die Wiederholungsregel", () => {
const JETZT = new Date("2026-10-05T08:00:00Z");
it("wartet nach dem ersten Fehlschlag 1 Minute, dann 5, 30 und 120", () => {
expect(WARTEZEITEN_MINUTEN).toEqual([1, 5, 30, 120]);
for (const [index, minuten] of WARTEZEITEN_MINUTEN.entries()) {
// `versucheGesamt` zaehlt den gerade gescheiterten mit: 1 → eine Minute.
const ergebnis = naechsterVersuch(index + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(ergebnis.naechsterVersuchAm.getTime() - JETZT.getTime()).toBe(minuten * 60_000);
}
});
it("gibt nach dem fünften Fehlschlag auf — aber sichtbar", () => {
const ergebnis = naechsterVersuch(WARTEZEITEN_MINUTEN.length + 1, JETZT);
expect(ergebnis.zustand).toBe(Zustellzustand.AUFGEGEBEN);
// Eine stille Warteschlange ist schlimmer als keine: der Zeitpunkt bleibt
// stehen, damit die Liste ihre Reihenfolge behaelt.
expect(ergebnis.naechsterVersuchAm).toEqual(JETZT);
});
});
/* ------------------------------------------------------------------ */
/* Mit Datenbank */
/* ------------------------------------------------------------------ */
const APP_URL_VORHER = process.env["APP_URL"];
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Petra Klein", rolle: "ADMIN" },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto", position: 0 },
});
/*
* Ein Befehlsschritt mit dem Platzhalter – damit der Passwort-Test etwas zu
* finden haette, wenn irgendwo ein Schrittinhalt in eine Mail geriete.
*/
const schritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: "BEFEHL",
pflicht: true,
position: 0,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}" -Name "Maria Huber"',
},
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { kunde, admin, techniker, versionId: version.id, schrittId: schritt.id };
}
async function laufAnlegenRoh(
versionId: string,
kundeId: string,
daten: {
titel?: string;
zugewiesenAnId?: string | null;
erstelltVonId?: string | null;
faelligAm?: Date | null;
status?: LaufStatus;
} = {},
) {
return testPrisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId,
titel: daten.titel ?? "PC Neuinstallation – PC-001",
zugewiesenAnId: daten.zugewiesenAnId ?? null,
erstelltVonId: daten.erstelltVonId ?? null,
faelligAm: daten.faelligAm ?? null,
status: daten.status ?? LaufStatus.OFFEN,
/*
* Die Zeitstempel gehoeren zum Status — `Lauf_begonnen_mit_zeitpunkt` und
* `Lauf_abgeschlossen_mit_zeitpunkt` verlangen das in der Datenbank. Ein
* Status ohne seinen Zeitpunkt ist hier gar nicht anlegbar, und das ist
* richtig so.
*/
...(daten.status === LaufStatus.IN_ARBEIT ? { gestartetAm: new Date() } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN
? { gestartetAm: new Date(), abgeschlossenAm: new Date() }
: {}),
},
});
}
/** SMTP auf den Faenger zeigen lassen. */
async function smtpEinrichten(): Promise<void> {
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
smtpHost: "127.0.0.1",
smtpPort: SMTP_PORT,
smtpTls: false,
smtpAbsender: "laufzettel@firma.example",
},
});
}
describe.runIf(datenbankVerfuegbar)("Einstellungen der Benachrichtigungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("gibt das SMTP-Passwort nie heraus, nur ob eines gesetzt ist", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{
smtpHost: "mail.firma.de",
smtpPort: 587,
smtpBenutzer: "laufzettel",
smtpPasswort: "streng-geheim",
smtpAbsender: "laufzettel@firma.de",
},
admin.id,
);
const gelesen = await meldungseinstellungenHolen();
expect(gelesen.smtp.host).toBe("mail.firma.de");
expect(gelesen.smtp.passwortGesetzt).toBe(true);
// Der Wert selbst taucht in der Antwort nirgends auf.
expect(JSON.stringify(gelesen)).not.toContain("streng-geheim");
// Und in der Datenbank steht er verschlüsselt, nicht im Klartext.
const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } });
expect(zeile.smtpPasswortVerschluesselt).toMatch(/^v[0-9]+\./);
expect(zeile.smtpPasswortVerschluesselt).not.toContain("streng-geheim");
});
it("schreibt kein Geheimnis ins Protokoll", async () => {
const { admin } = await aufbau();
await meldungseinstellungenSpeichern(
{ smtpHost: "mail.firma.de", smtpPort: 587, smtpPasswort: "streng-geheim" },
admin.id,
);
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.de/laufzettel", webhookGeheimnis: "hook-geheim" },
admin.id,
);
const protokoll = await testPrisma.protokoll.findMany();
const alsText = JSON.stringify(protokoll);
// Das Protokoll ist ansehbar (#023) – hier wäre die Verschlüsselung sinnlos.
expect(alsText).not.toContain("streng-geheim");
expect(alsText).not.toContain("hook-geheim");
expect(alsText).toContain("Benachrichtigungen geändert");
});
it("weist eine unbrauchbare Webhook-Adresse und einen falschen Port ab", async () => {
const { admin } = await aufbau();
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "nicht-mal-eine-url" }, admin.id),
).rejects.toThrow(/gültige URL/);
await expect(
meldungseinstellungenSpeichern({ webhookUrl: "ftp://hook.firma.de" }, admin.id),
).rejects.toThrow(/http/);
await expect(
meldungseinstellungenSpeichern({ smtpHost: "mail.firma.de", smtpPort: 70_000 }, admin.id),
).rejects.toThrow(/zwischen 1 und 65535/);
});
});
describe.runIf(datenbankVerfuegbar)("Einreihen", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
});
it("meldet eine Zuweisung an den Zugewiesenen, nicht an den Zuweisenden", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany();
expect(zustellungen).toHaveLength(1);
expect(zustellungen[0]).toMatchObject({
anlass: Meldeanlass.LAUF_ZUGEWIESEN,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
zustand: Zustellzustand.OFFEN,
versuche: 0,
});
expect(zustellungen[0]?.rumpf).toContain("https://laufzettel.firma.example/laeufe/");
expect(zustellungen[0]?.rumpf).toContain("Petra Klein");
});
it("schickt niemandem eine Mail über die eigene Tat", async () => {
/*
* Der haeufigste Grund, Benachrichtigungen ganz abzuschalten: Post ueber
* das, was man gerade selbst geklickt hat.
*/
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
const erzeugt = await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, techniker.id);
expect(erzeugt).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(0);
});
it("meldet einen Abschluss auch dem, der den Lauf angelegt hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
});
// Abgeschlossen hat der Techniker – also erfaehrt es der Ersteller.
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
const empfaenger = (await testPrisma.zustellung.findMany()).map((z) => z.empfaenger);
expect(empfaenger).toEqual(["admin@firma.example"]);
});
it("schickt nichts an jemanden, der Benachrichtigungen abgeschaltet hat", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({
where: { id: techniker.id },
data: { meldungenAktiv: false },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("schickt nichts an ein deaktiviertes Konto", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { aktiv: false } });
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("achtet die Mandantengrenze", async () => {
/*
* Eine Meldung traegt Lauftitel und Geraet. Ohne diese Pruefung waere die
* Benachrichtigung der eine Weg nach draussen, den `sichtbareKundenIds`
* nicht abdeckt.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
try {
const fremd = await testPrisma.kunde.create({
data: { name: "Fremd GmbH", kuerzel: "FREMD" },
});
// Der Techniker darf nur den eigenen Kunden sehen …
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: kunde.id },
});
// … ist aber einem Lauf des fremden zugewiesen.
const lauf = await laufAnlegenRoh(versionId, fremd.id, { zugewiesenAnId: techniker.id });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
} finally {
if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher;
}
});
it("meldet einen fehlgeschlagenen Abgleich allen Administratoren", async () => {
const { kunde, admin } = await aufbau();
const zweiterAdmin = await testPrisma.benutzer.create({
data: { email: "admin2@firma.example", name: "Zweiter Admin", rolle: "ADMIN" },
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { empfaenger: "asc" } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual(["admin2@firma.example", "admin@firma.example"]);
expect(zustellungen[0]?.rumpf).toContain("Verbindung abgelehnt");
expect(zustellungen[0]?.verzeichnisquelleId).toBe(quelle.id);
expect(admin.id).toBeTruthy();
expect(zweiterAdmin.id).toBeTruthy();
});
it("meldet einen überfälligen Lauf genau einmal, auch bei jedem Durchgang", async () => {
/*
* Der Zeitplan sieht **jede Minute** nach. Ohne den partiellen eindeutigen
* Index bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende
* steht, 2880 Mails — und jede einzelne davon waere fuer sich korrekt.
*/
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
expect(await ueberfaelligeEinreihen()).toBe(1);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await ueberfaelligeEinreihen()).toBe(0);
expect(await testPrisma.zustellung.count()).toBe(1);
});
it("hält die Einmaligkeit auch gegen einen Schreiber an der Service-Schicht vorbei", async () => {
const { kunde, techniker, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await ueberfaelligeEinreihen();
await expect(
testPrisma.zustellung.create({
data: {
anlass: Meldeanlass.LAUF_UEBERFAELLIG,
weg: Zustellweg.MAIL,
empfaenger: "technik@firma.example",
betreff: "noch einmal",
rumpf: "noch einmal",
laufId: lauf.id,
benutzerId: techniker.id,
},
}),
).rejects.toThrow();
});
it("meldet einen überfälligen Lauf nicht, wenn er abgeschlossen ist", async () => {
const { kunde, techniker, versionId } = await aufbau();
await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.ABGESCHLOSSEN,
});
expect(await ueberfaelligeEinreihen()).toBe(0);
});
it("legt für einen Lauf ohne Zuweisung keine Zeile an", async () => {
const { kunde, admin, versionId } = await aufbau();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: null });
expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0);
});
it("reiht neben der Mail eine Webhook-Zeile ein, wenn einer eingerichtet ist", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await meldungseinstellungenSpeichern(
{ webhookUrl: "https://hook.firma.example/laufzettel", webhookGeheimnis: "geheim" },
admin.id,
);
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { weg: "asc" } });
expect(zustellungen.map((z) => z.weg)).toEqual([Zustellweg.MAIL, Zustellweg.WEBHOOK]);
const hook = zustellungen.find((z) => z.weg === Zustellweg.WEBHOOK);
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
expect(hook?.benutzerId).toBeNull();
expect(JSON.parse(hook?.rumpf ?? "{}")).toMatchObject({ anlass: "LAUF_ZUGEWIESEN" });
});
});
/* ------------------------------------------------------------------ */
/* Senden */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Die Warteschlange", () => {
beforeAll(() => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
});
afterAll(() => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
});
beforeEach(async () => {
await datenbankLeeren();
if (mailpitDa) await mailpitLeeren();
});
it.runIf(mailpitDa)("schickt alle vier Anlässe an die richtigen Empfänger", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
const quelle = await testPrisma.verzeichnisquelle.create({
data: {
kundeId: kunde.id,
name: "AD Zentrale",
url: "ldaps://dc1.firma.test:636",
bindDn: "cn=admin,dc=firma,dc=test",
bindPasswortVerschluesselt: verschluesseln("egal"),
basisDn: "dc=firma,dc=test",
filter: "(objectClass=user)",
attributZuordnung: {},
},
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt");
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis).toMatchObject({ fehlgeschlagen: 0, aufgegeben: 0 });
expect(ergebnis?.gesendet).toBe(4);
const nachrichten = await mailpitNachrichten();
expect(nachrichten).toHaveLength(4);
const nachEmpfaenger = new Map<string, string[]>();
for (const nachricht of nachrichten) {
for (const an of nachricht.To) {
nachEmpfaenger.set(an.Address, [...(nachEmpfaenger.get(an.Address) ?? []), nachricht.Subject]);
}
}
/*
* Der Techniker: zugewiesen und überfällig. Der Admin: abgeschlossen (er hat
* den Lauf angelegt) und der Abgleich.
*
* Geprueft wird **ohne Annahme ueber die Reihenfolge** — die haengt an der
* Sortierung der Betreffe, und die hatte ich beim ersten Lauf falsch
* geraten. Eine Sortierung ist hier auch nicht die Zusage; die Zusage ist,
* wer was bekommt.
*/
const anTechniker = nachEmpfaenger.get("technik@firma.example") ?? [];
expect(anTechniker).toHaveLength(2);
expect(anTechniker.some((betreff) => betreff.includes("ist dir zugewiesen"))).toBe(true);
expect(anTechniker.some((betreff) => betreff.includes("überfällig"))).toBe(true);
const anAdmin = nachEmpfaenger.get("admin@firma.example") ?? [];
expect(anAdmin).toHaveLength(2);
expect(anAdmin.some((betreff) => betreff.includes("abgeschlossen"))).toBe(true);
expect(anAdmin.some((betreff) => betreff.includes("Verzeichnis-Abgleich"))).toBe(true);
// Und alle Zeilen stehen jetzt auf GESENDET, mit Zeitpunkt.
const zeilen = await testPrisma.zustellung.findMany();
expect(zeilen).toHaveLength(4);
for (const zeile of zeilen) {
expect(zeile.zustand).toBe(Zustellzustand.GESENDET);
expect(zeile.gesendetAm).toBeInstanceOf(Date);
expect(zeile.versuche).toBe(1);
}
});
it.runIf(mailpitDa)("hat in keiner Nachricht ein erzeugtes Passwort", async () => {
/*
* Ueber **alle** Rumpftexte, wie in #012. Die Vorlage im Aufbau hat einen
* Befehlsschritt mit `{{generiertes_passwort}}` und `New-ADUser` — kaeme
* irgendwo ein Schrittinhalt in eine Mail, stuende es hier.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, {
zugewiesenAnId: techniker.id,
erstelltVonId: admin.id,
faelligAm: new Date("2026-10-01T00:00:00Z"),
status: LaufStatus.IN_ARBEIT,
});
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await ueberfaelligeEinreihen();
await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id);
await warteschlangeAbarbeiten();
const nachrichten = await mailpitNachrichten();
expect(nachrichten.length).toBeGreaterThan(0);
for (const nachricht of nachrichten) {
const text = await mailpitText(nachricht.ID);
expect(text).not.toContain("{{generiertes_passwort}}");
expect(text).not.toContain("New-ADUser");
expect(text).not.toContain("AccountPassword");
// Auch kein aufgeloestes Passwort: 20 Zeichen aus dem Alphabet von #012.
expect(text).not.toMatch(/-AccountPassword/);
}
// Und in der Datenbank steht es auch nicht.
const alsText = JSON.stringify(await testPrisma.zustellung.findMany());
expect(alsText).not.toContain("{{");
expect(alsText).not.toContain("New-ADUser");
});
it("wiederholt eine fehlgeschlagene Zustellung und macht sie danach sichtbar", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
// **Kein** SMTP eingerichtet: jeder Versuch muss scheitern.
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* **Nicht** ein festes Datum: `naechsterVersuchAm` steht beim Einreihen auf
* `now()` der Datenbank. Ein Zeitpunkt in der Vergangenheit liesse den
* Durchgang die Zeile gar nicht erst anfassen — beim ersten Lauf war der
* Test deshalb rot, und zwar ohne Fehler im Code.
*/
const jetzt = new Date(Date.now() + 1_000);
const erster = await warteschlangeAbarbeiten(jetzt);
expect(erster).toMatchObject({ gesendet: 0, fehlgeschlagen: 1, aufgegeben: 0 });
let zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.versuche).toBe(1);
expect(zeile.letzterFehler).toContain("SMTP");
// Eine Minute später, nicht sofort.
expect(zeile.naechsterVersuchAm.getTime()).toBe(jetzt.getTime() + 60_000);
// Vor der Wartezeit fasst der Durchgang die Zeile nicht an.
expect(await warteschlangeAbarbeiten(jetzt)).toMatchObject({ gesendet: 0, fehlgeschlagen: 0 });
expect((await testPrisma.zustellung.findFirstOrThrow()).versuche).toBe(1);
// Danach die Versuche zwei bis vier, jeder nach seiner Wartezeit.
for (const erwarteteWartezeit of WARTEZEITEN_MINUTEN.slice(1)) {
const spaeter = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const vorherVersuche = zeile.versuche;
await warteschlangeAbarbeiten(spaeter);
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.versuche).toBe(vorherVersuche + 1);
expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(zeile.naechsterVersuchAm.getTime() - spaeter.getTime()).toBe(erwarteteWartezeit * 60_000);
}
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length);
// Der fünfte Fehlschlag gibt auf – und bleibt sichtbar.
const zuletzt = new Date(zeile.naechsterVersuchAm.getTime() + 1_000);
const ergebnis = await warteschlangeAbarbeiten(zuletzt);
expect(ergebnis).toMatchObject({ aufgegeben: 1 });
zeile = await testPrisma.zustellung.findFirstOrThrow();
expect(zeile.zustand).toBe(Zustellzustand.AUFGEGEBEN);
expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length + 1);
expect(zeile.letzterFehler).toBeTruthy();
// Und wird nicht mehr angefasst.
expect(await warteschlangeAbarbeiten(new Date(zuletzt.getTime() + 86_400_000))).toMatchObject({
gesendet: 0,
fehlgeschlagen: 0,
aufgegeben: 0,
});
});
it.runIf(mailpitDa)("schickt bei zwei gleichzeitigen Durchgängen nichts zweimal", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await smtpEinrichten();
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
/*
* Beide zugleich starten. Einer bekommt die Sperre, der andere `undefined` —
* ohne sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
*/
const [eins, zwei] = await Promise.all([warteschlangeAbarbeiten(), warteschlangeAbarbeiten()]);
const ergebnisse = [eins, zwei];
expect(ergebnisse.filter((e) => e === undefined)).toHaveLength(1);
expect(ergebnisse.find((e) => e !== undefined)).toMatchObject({ gesendet: 1 });
expect(await mailpitNachrichten()).toHaveLength(1);
});
});
/* ------------------------------------------------------------------ */
/* Webhook gegen einen echten Empfaenger */
/* ------------------------------------------------------------------ */
describe.runIf(datenbankVerfuegbar)("Der Webhook", () => {
let server: Server | undefined;
let port = 0;
let empfangen: { kopf: string | undefined; rumpf: string }[] = [];
let antwortStatus = 200;
beforeAll(async () => {
process.env["APP_URL"] = "https://laufzettel.firma.example";
server = createServer((anfrage, antwort) => {
let rumpf = "";
anfrage.on("data", (stueck) => {
rumpf += stueck;
});
anfrage.on("end", () => {
empfangen.push({
kopf: anfrage.headers[SIGNATUR_KOPF.toLowerCase()] as string | undefined,
rumpf,
});
antwort.writeHead(antwortStatus).end(antwortStatus === 200 ? "ok" : "kaputt");
});
});
await new Promise<void>((fertig) => {
server?.listen(0, "127.0.0.1", () => fertig());
});
const adresse = server?.address();
port = typeof adresse === "object" && adresse ? adresse.port : 0;
});
afterAll(async () => {
if (APP_URL_VORHER === undefined) delete process.env["APP_URL"];
else process.env["APP_URL"] = APP_URL_VORHER;
await new Promise<void>((fertig) => {
server?.close(() => fertig());
});
});
beforeEach(async () => {
await datenbankLeeren();
empfangen = [];
antwortStatus = 200;
});
afterEach(() => {
antwortStatus = 200;
});
it("kommt an, und die Signatur lässt sich mit dem Geheimnis nachrechnen", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: {
webhookUrl: `http://127.0.0.1:${port}/laufzettel`,
webhookGeheimnisVerschluesselt: verschluesseln("hook-geheim"),
},
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
// Nur die Webhook-Zeile: kein SMTP eingerichtet, die Mailzeile scheitert.
const ergebnis = await warteschlangeAbarbeiten();
expect(ergebnis?.gesendet).toBe(1);
expect(empfangen).toHaveLength(1);
const nachricht = empfangen[0];
if (!nachricht?.kopf) throw new Error("Keine Signatur im Kopf.");
const jetzt = Math.floor(Date.now() / 1000);
expect(signaturPruefen("hook-geheim", nachricht.rumpf, nachricht.kopf, jetzt)).toEqual({
gueltig: true,
});
// Mit einem falschen Geheimnis nicht.
expect(signaturPruefen("falsch", nachricht.rumpf, nachricht.kopf, jetzt).gueltig).toBe(false);
expect(JSON.parse(nachricht.rumpf)).toMatchObject({
anlass: "LAUF_ZUGEWIESEN",
laufId: lauf.id,
});
});
it("gilt als fehlgeschlagen, wenn die Gegenseite nicht mit 2xx antwortet", async () => {
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
antwortStatus = 500;
await warteschlangeAbarbeiten(new Date(Date.now() + 1_000));
const hook = await testPrisma.zustellung.findFirstOrThrow({ where: { weg: Zustellweg.WEBHOOK } });
expect(hook.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN);
expect(hook.letzterFehler).toContain("500");
});
it("geht ohne Geheimnis hinaus, dann aber ohne Signatur", async () => {
/*
* Ein Webhook ohne Geheimnis ist zulaessig — manche Gegenstellen haben keine
* Pruefung. Dann fehlt die Kopfzeile, statt dass eine leere Signatur
* Sicherheit vortaeuscht.
*/
const { kunde, admin, techniker, versionId } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` },
});
const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id });
await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id);
await warteschlangeAbarbeiten();
expect(empfangen).toHaveLength(1);
expect(empfangen[0]?.kopf).toBeUndefined();
});
});
+14
View File
@@ -99,6 +99,12 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig",
"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",
]);
});
@@ -111,6 +117,9 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
"Einstellung_einzelne_zeile",
"Einstellung_smtp_passwort_verschluesselt",
"Einstellung_smtp_port_bereich",
"Einstellung_webhook_geheimnis_verschluesselt",
"Geraet_registriert_braucht_namen",
"Geraet_tagcode_form",
"Kunde_kuerzel_form",
@@ -123,6 +132,11 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
"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",
]);
});
+8 -3
View File
@@ -18,7 +18,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const abgleichAusfuehren = vi.fn();
const faelligeQuellen = vi.fn();
/** 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. */
class AbgleichLaeuftBereitsFehler extends Error {}
@@ -27,7 +27,12 @@ vi.mock("@/server/services/abgleich", () => ({
AbgleichLaeuftBereitsFehler,
abgleichAusfuehren: (...args: unknown[]) => abgleichAusfuehren(...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 } =
@@ -57,7 +62,7 @@ describe("Ein Durchgang des Zeitplans", () => {
* Schluessel, liefe an der ersten vorbei — und ein von Hand angestossener
* 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
* des Dienstes greift, und ohne Id, weil kein Mensch es war. Der Dienst macht