feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) 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 22:52:36 +02:00
co-authored by Claude Opus 5
parent 1be73b266c
commit a417426f43
36 changed files with 3573 additions and 130 deletions
+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({