feat(meldungen): Benachrichtigungen per Mail und Webhook (#54)
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:
co-authored by
Claude Opus 5
parent
1be73b266c
commit
a417426f43
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user