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,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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Die Texte der Benachrichtigungen (#034).
|
||||
*
|
||||
* Framework-frei und ohne Datenbank, weil hier die wichtigste Zusage dieses
|
||||
* Meilensteins sitzt und einzeln pruefbar sein muss:
|
||||
*
|
||||
* **In keine Meldung kommt ein Schrittinhalt.** Kein Wert, keine Notiz, kein
|
||||
* Befehl — und damit auch kein `{{generiertes_passwort}}`. Eine Mail mit dem
|
||||
* Lauf-Inhalt waere genau die Luecke, die dieses Projekt an jeder anderen Stelle
|
||||
* geschlossen hat: das Passwort entsteht bei jeder Anzeige neu und wird nirgends
|
||||
* gespeichert, aber eine Mail liegt fuer immer in einem Postfach, oft auf einem
|
||||
* Server, der nicht im Haus steht.
|
||||
*
|
||||
* Deshalb nimmt jede Funktion hier **nur** Kopfdaten: Titel, Status, Beteiligte,
|
||||
* Frist, Link. Der Typ laesst gar nichts anderes zu — das ist die Absicht. Wer
|
||||
* einen Schrittinhalt in eine Meldung bekommen wollte, muesste diese Datei
|
||||
* aendern, und dann steht er vor diesem Kommentar.
|
||||
*/
|
||||
|
||||
import { Meldeanlass } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Die drei Anlaesse, die zu einem Lauf gehoeren.
|
||||
*
|
||||
* `ABGLEICH_FEHLGESCHLAGEN` ist ausgeschlossen, und zwar **im Typ**: er gehoert
|
||||
* zu einer Verzeichnisquelle, nicht zu einem Lauf. Mit einem Wurf zur Laufzeit
|
||||
* waere derselbe Fehler erst im Betrieb aufgefallen.
|
||||
*/
|
||||
export type LaufAnlass = Exclude<Meldeanlass, typeof Meldeanlass.ABGLEICH_FEHLGESCHLAGEN>;
|
||||
|
||||
/** Was eine Lauf-Meldung ueber den Lauf wissen darf. Mehr gibt es nicht. */
|
||||
export type LaufKopf = {
|
||||
readonly titel: string;
|
||||
/** Hostname, Bezeichnung oder Tag-Code — was die Oberflaeche auch zeigt. */
|
||||
readonly geraet?: string | null;
|
||||
readonly person?: string | null;
|
||||
readonly faelligAm?: Date | null;
|
||||
readonly vorlage?: string | null;
|
||||
/** Wer zugewiesen oder abgeschlossen hat. */
|
||||
readonly handelnder?: string | null;
|
||||
};
|
||||
|
||||
export type Meldung = { readonly betreff: string; readonly rumpf: string };
|
||||
|
||||
/** `05.10.2026` – ohne Uhrzeit, weil eine Frist ein Tag ist. */
|
||||
function datum(wert: Date): string {
|
||||
return wert.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
}
|
||||
|
||||
/** Die Zeilen, die jeden Lauf beschreiben – leere bleiben weg. */
|
||||
function kopfzeilen(kopf: LaufKopf): string[] {
|
||||
const zeilen: string[] = [];
|
||||
if (kopf.vorlage) zeilen.push(`Vorlage: ${kopf.vorlage}`);
|
||||
if (kopf.geraet) zeilen.push(`Gerät: ${kopf.geraet}`);
|
||||
if (kopf.person) zeilen.push(`Person: ${kopf.person}`);
|
||||
if (kopf.faelligAm) zeilen.push(`Fällig: ${datum(kopf.faelligAm)}`);
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
function zusammensetzen(absaetze: readonly (string | undefined)[]): string {
|
||||
return absaetze.filter((teil): teil is string => Boolean(teil)).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldung zu einem Lauf.
|
||||
*
|
||||
* `link` ist die vollstaendige Adresse aus `APP_URL` — eine Mail mit einem
|
||||
* relativen Pfad ist eine Mail ohne Link.
|
||||
*/
|
||||
export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): Meldung {
|
||||
const zeilen = kopfzeilen(kopf);
|
||||
const daten = zeilen.length > 0 ? zeilen.join("\n") : undefined;
|
||||
|
||||
switch (anlass) {
|
||||
case Meldeanlass.LAUF_ZUGEWIESEN:
|
||||
return {
|
||||
betreff: `Laufzettel: „${kopf.titel}“ ist dir zugewiesen`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.handelnder
|
||||
? `${kopf.handelnder} hat dir diesen Laufzettel zugewiesen.`
|
||||
: "Dir wurde ein Laufzettel zugewiesen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
case Meldeanlass.LAUF_UEBERFAELLIG:
|
||||
return {
|
||||
betreff: `Laufzettel überfällig: „${kopf.titel}“`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.faelligAm
|
||||
? `Dieser Laufzettel war am ${datum(kopf.faelligAm)} fällig und ist noch offen.`
|
||||
: "Dieser Laufzettel ist überfällig und noch offen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
case Meldeanlass.LAUF_ABGESCHLOSSEN:
|
||||
return {
|
||||
betreff: `Laufzettel abgeschlossen: „${kopf.titel}“`,
|
||||
rumpf: zusammensetzen([
|
||||
kopf.handelnder
|
||||
? `${kopf.handelnder} hat diesen Laufzettel abgeschlossen.`
|
||||
: "Dieser Laufzettel wurde abgeschlossen.",
|
||||
daten,
|
||||
link,
|
||||
]),
|
||||
};
|
||||
|
||||
default: {
|
||||
/*
|
||||
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
|
||||
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
|
||||
* totem Code im Ergebnis.
|
||||
*/
|
||||
const unbekannt: never = anlass;
|
||||
throw new Error(`laufMeldung kennt den Anlass ${String(unbekannt)} nicht.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Meldung zu einem fehlgeschlagenen Verzeichnis-Abgleich. */
|
||||
export function abgleichMeldung(quelle: string, grund: string, link: string): Meldung {
|
||||
return {
|
||||
betreff: `Laufzettel: Verzeichnis-Abgleich „${quelle}“ fehlgeschlagen`,
|
||||
rumpf: zusammensetzen([
|
||||
`Der Abgleich der Verzeichnisquelle „${quelle}“ ist fehlgeschlagen.`,
|
||||
`Grund: ${grund}`,
|
||||
"Bis das behoben ist, bleiben neue und geänderte Konten aus dem Verzeichnis aus. " +
|
||||
"Vorhandene Personen bleiben unverändert — der Abgleich löscht nichts.",
|
||||
link,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Rumpf eines Webhooks.
|
||||
*
|
||||
* Dasselbe Prinzip: Kopfdaten und ein Link, keine Schrittinhalte. Als JSON, weil
|
||||
* die Gegenseite ein Programm ist — und mit `anlass` als erstem Feld, damit sich
|
||||
* ohne Auspacken filtern laesst.
|
||||
*/
|
||||
export function webhookRumpf(
|
||||
anlass: Meldeanlass,
|
||||
betreff: string,
|
||||
link: string,
|
||||
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
|
||||
gesendetAm: Date,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
anlass,
|
||||
betreff,
|
||||
link,
|
||||
laufId: bezug.laufId ?? null,
|
||||
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
|
||||
zeitpunkt: gesendetAm.toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Die Signatur eines Webhooks (#034).
|
||||
*
|
||||
* Framework-frei, weil die Gegenseite sie nachrechnen muss — und weil genau das
|
||||
* geprueft werden soll, ohne einen Server zu starten. Das Format ist an das von
|
||||
* Stripe angelehnt, aus einem praktischen Grund: wer schon einmal einen Webhook
|
||||
* verdrahtet hat, kennt es, und `docs/betrieb/` kann ein Beispiel in drei Zeilen
|
||||
* zeigen.
|
||||
*
|
||||
* X-Laufzettel-Signatur: t=1762334400,v1=9f86d0818...
|
||||
*
|
||||
* **Der Zeitstempel ist Teil der signierten Nachricht, nicht Beiwerk.** Stuende
|
||||
* er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu brechen — und
|
||||
* eine abgefangene Nachricht liesse sich in einem Jahr noch einspielen. Signiert
|
||||
* wird deshalb `<zeitstempel>.<rumpf>`.
|
||||
*/
|
||||
|
||||
/** Wie alt eine Nachricht hoechstens sein darf. Fuenf Minuten, wie bei Stripe. */
|
||||
export const TOLERANZ_SEKUNDEN = 300;
|
||||
|
||||
export const SIGNATUR_KOPF = "X-Laufzettel-Signatur";
|
||||
|
||||
function berechnen(geheimnis: string, zeitstempel: number, rumpf: string): string {
|
||||
return createHmac("sha256", geheimnis).update(`${zeitstempel}.${rumpf}`).digest("hex");
|
||||
}
|
||||
|
||||
/** Den Wert der Kopfzeile bauen. `zeitstempel` in Sekunden. */
|
||||
export function signaturBauen(geheimnis: string, rumpf: string, zeitstempel: number): string {
|
||||
return `t=${zeitstempel},v1=${berechnen(geheimnis, zeitstempel, rumpf)}`;
|
||||
}
|
||||
|
||||
export type SignaturPruefung =
|
||||
{ readonly gueltig: true } | { readonly gueltig: false; readonly grund: "form" | "abgelaufen" | "falsch" };
|
||||
|
||||
/**
|
||||
* Die Gegenprobe – so, wie die Gegenseite sie machen soll.
|
||||
*
|
||||
* Exportiert und geprueft, damit `docs/betrieb/` nicht beschreiben muss, was
|
||||
* niemand nachgerechnet hat.
|
||||
*/
|
||||
export function signaturPruefen(
|
||||
geheimnis: string,
|
||||
rumpf: string,
|
||||
kopf: string,
|
||||
jetztSekunden: number,
|
||||
toleranzSekunden = TOLERANZ_SEKUNDEN,
|
||||
): SignaturPruefung {
|
||||
const teile = new Map<string, string>();
|
||||
for (const stueck of kopf.split(",")) {
|
||||
const trenner = stueck.indexOf("=");
|
||||
if (trenner > 0) teile.set(stueck.slice(0, trenner).trim(), stueck.slice(trenner + 1).trim());
|
||||
}
|
||||
|
||||
const roherStempel = teile.get("t");
|
||||
const mitgeschickt = teile.get("v1");
|
||||
if (!roherStempel || !mitgeschickt) return { gueltig: false, grund: "form" };
|
||||
|
||||
const zeitstempel = Number.parseInt(roherStempel, 10);
|
||||
if (!Number.isFinite(zeitstempel)) return { gueltig: false, grund: "form" };
|
||||
|
||||
/*
|
||||
* In **beide** Richtungen pruefen. Nur „zu alt“ abzufangen liesse eine
|
||||
* Nachricht mit Zeitstempel im Jahr 2040 fuer immer gueltig sein.
|
||||
*/
|
||||
if (Math.abs(jetztSekunden - zeitstempel) > toleranzSekunden) {
|
||||
return { gueltig: false, grund: "abgelaufen" };
|
||||
}
|
||||
|
||||
const erwartet = berechnen(geheimnis, zeitstempel, rumpf);
|
||||
/*
|
||||
* Zeitkonstant vergleichen. Ein `===` auf Zeichenketten bricht beim ersten
|
||||
* Unterschied ab; aus der Dauer liesse sich die richtige Signatur Zeichen fuer
|
||||
* Zeichen erraten. `timingSafeEqual` verlangt gleiche Laenge, deshalb die
|
||||
* Pruefung davor — die Laenge ist kein Geheimnis, sie ist immer 64.
|
||||
*/
|
||||
const a = Buffer.from(erwartet, "utf8");
|
||||
const b = Buffer.from(mitgeschickt, "utf8");
|
||||
if (a.length !== b.length) return { gueltig: false, grund: "falsch" };
|
||||
return timingSafeEqual(a, b) ? { gueltig: true } : { gueltig: false, grund: "falsch" };
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Zustellweg, Zustellzustand, type Meldeanlass } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Die letzten Zustellungen – fuer Admin → Zustellungen (#034).
|
||||
*
|
||||
* **Warum es diese Seite gibt:** eine stille Warteschlange ist schlimmer als
|
||||
* keine. Ohne sie merkt niemand, dass seit drei Wochen keine Mail mehr
|
||||
* hinausgeht, weil das SMTP-Passwort abgelaufen ist — jeder einzelne Lauf
|
||||
* funktioniert weiter, nur erfaehrt niemand davon.
|
||||
*
|
||||
* Keine Mandantensicht: das Recht `verwaltung` hat nur, wer alles sehen darf,
|
||||
* und eine Zustellung gehoert zum Betrieb der Anwendung, nicht zu einem Kunden.
|
||||
* Der **Rumpf** wird trotzdem nicht ausgeliefert — er traegt Lauftitel und
|
||||
* Geraetenamen, und fuer „laeuft der Versand?“ braucht es ihn nicht.
|
||||
*/
|
||||
|
||||
export type ZustellZeile = {
|
||||
readonly id: string;
|
||||
readonly anlass: Meldeanlass;
|
||||
readonly weg: Zustellweg;
|
||||
readonly empfaenger: string;
|
||||
readonly betreff: string;
|
||||
readonly zustand: Zustellzustand;
|
||||
readonly versuche: number;
|
||||
readonly letzterFehler: string | null;
|
||||
readonly naechsterVersuchAm: Date;
|
||||
readonly gesendetAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
readonly laufId: string | null;
|
||||
};
|
||||
|
||||
export const LISTE_HOECHSTENS = 200;
|
||||
|
||||
export type ZustellFilter = {
|
||||
readonly zustand?: Zustellzustand;
|
||||
};
|
||||
|
||||
export async function zustellungenHolen(filter: ZustellFilter = {}): Promise<readonly ZustellZeile[]> {
|
||||
const zeilen = await prisma.zustellung.findMany({
|
||||
where: filter.zustand ? { zustand: filter.zustand } : {},
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: LISTE_HOECHSTENS,
|
||||
select: {
|
||||
id: true,
|
||||
anlass: true,
|
||||
weg: true,
|
||||
empfaenger: true,
|
||||
betreff: true,
|
||||
zustand: true,
|
||||
versuche: true,
|
||||
letzterFehler: true,
|
||||
naechsterVersuchAm: true,
|
||||
gesendetAm: true,
|
||||
erstelltAm: true,
|
||||
laufId: true,
|
||||
},
|
||||
});
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
export type ZustellZaehler = Readonly<Record<Zustellzustand, number>>;
|
||||
|
||||
/**
|
||||
* Wie viele in welchem Zustand.
|
||||
*
|
||||
* Die Zahl der **aufgegebenen** ist die eine, die jemand sehen muss: alles
|
||||
* andere regelt sich von selbst.
|
||||
*/
|
||||
export async function zustellungenZaehlen(): Promise<ZustellZaehler> {
|
||||
const gruppen = await prisma.zustellung.groupBy({ by: ["zustand"], _count: { _all: true } });
|
||||
const leer: Record<Zustellzustand, number> = {
|
||||
[Zustellzustand.OFFEN]: 0,
|
||||
[Zustellzustand.GESENDET]: 0,
|
||||
[Zustellzustand.FEHLGESCHLAGEN]: 0,
|
||||
[Zustellzustand.AUFGEGEBEN]: 0,
|
||||
};
|
||||
for (const gruppe of gruppen) leer[gruppe.zustand] = gruppe._count._all;
|
||||
return leer;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import { 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
|
||||
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
|
||||
import { sichtbareKundenIds } from "@/server/mandant";
|
||||
import { mitSperre } from "@/server/services/sperre";
|
||||
|
||||
/**
|
||||
* Benachrichtigungen per Mail und Webhook (#034).
|
||||
*
|
||||
* Vier Anlaesse, nicht mehr: **zugewiesen**, **ueberfaellig**, **abgeschlossen**,
|
||||
* **Abgleich fehlgeschlagen**. Ein allgemeiner Benachrichtigungsbaukasten ist
|
||||
* eine Phase fuer sich; diese vier decken ab, was im Betrieb wirklich fehlt.
|
||||
*
|
||||
* Drei Entscheidungen tragen das Ganze:
|
||||
*
|
||||
* 1. **Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht.** Ein haengender
|
||||
* Mailserver darf kein Abhaken blockieren. Der Dienst schreibt eine Zeile in
|
||||
* `Zustellung` — das ist eine Einfuegung in derselben Datenbank, die der
|
||||
* Vorgang ohnehin anfasst —, und der Zeitplan schickt sie.
|
||||
* 2. **Betreff und Rumpf stehen fertig in der Zeile.** Eine Meldung sagt, was
|
||||
* **damals** war. Baute der Zeitplan den Text erst beim Senden, stuende in
|
||||
* der Mail der Stand von jetzt.
|
||||
* 3. **In keiner Meldung steht ein Schrittinhalt.** Darueber wacht
|
||||
* `src/lib/meldungen.ts`; hier kommt nur hinzu, dass auch die Empfaengerwahl
|
||||
* die Mandantengrenze achtet.
|
||||
*/
|
||||
|
||||
export class MeldungsFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "MeldungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
|
||||
*
|
||||
* Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
|
||||
* Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
|
||||
* Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
|
||||
* wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
|
||||
*/
|
||||
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
|
||||
|
||||
/** Wie viele Zeilen ein Durchgang hoechstens anfasst. */
|
||||
const BUENDEL = 50;
|
||||
|
||||
/**
|
||||
* Wie lange die Sperre der Warteschlange gilt.
|
||||
*
|
||||
* Kurz, weil ein Durchgang kurz ist — und weil ein Absturz mitten im Senden die
|
||||
* Warteschlange sonst fuer Stunden stillstellte.
|
||||
*/
|
||||
const SPERRE_MINUTEN = 10;
|
||||
|
||||
export const WARTESCHLANGE_SCHLUESSEL = "zustellung:warteschlange";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Einstellungen */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type SmtpAngaben = {
|
||||
readonly host: string | null;
|
||||
readonly port: number | null;
|
||||
readonly tls: boolean;
|
||||
readonly benutzer: string | null;
|
||||
readonly absender: string | null;
|
||||
/**
|
||||
* Nur, **ob** eines gesetzt ist — nie der Wert.
|
||||
*
|
||||
* Dieselbe Form wie beim LDAP-Bind-Passwort: ein Feld, das es nicht in eine
|
||||
* Antwort schafft, kann auch nicht versehentlich hinausgehen.
|
||||
*/
|
||||
readonly passwortGesetzt: boolean;
|
||||
};
|
||||
|
||||
export type WebhookAngaben = {
|
||||
readonly url: string | null;
|
||||
readonly geheimnisGesetzt: boolean;
|
||||
};
|
||||
|
||||
export type MeldungsEinstellungen = {
|
||||
readonly smtp: SmtpAngaben;
|
||||
readonly webhook: WebhookAngaben;
|
||||
/** Ohne `APP_URL` traegt keine Meldung einen brauchbaren Link. */
|
||||
readonly appUrl: string | null;
|
||||
};
|
||||
|
||||
export async function meldungseinstellungenHolen(): Promise<MeldungsEinstellungen> {
|
||||
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
|
||||
return {
|
||||
smtp: {
|
||||
host: zeile?.smtpHost ?? null,
|
||||
port: zeile?.smtpPort ?? null,
|
||||
tls: zeile?.smtpTls ?? true,
|
||||
benutzer: zeile?.smtpBenutzer ?? null,
|
||||
absender: zeile?.smtpAbsender ?? null,
|
||||
passwortGesetzt: Boolean(zeile?.smtpPasswortVerschluesselt),
|
||||
},
|
||||
webhook: {
|
||||
url: zeile?.webhookUrl ?? null,
|
||||
geheimnisGesetzt: Boolean(zeile?.webhookGeheimnisVerschluesselt),
|
||||
},
|
||||
appUrl: process.env["APP_URL"]?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export type MeldungsAenderung = {
|
||||
readonly smtpHost?: string | null;
|
||||
readonly smtpPort?: number | null;
|
||||
readonly smtpTls?: boolean;
|
||||
readonly smtpBenutzer?: string | null;
|
||||
/** `undefined` laesst es stehen, `null` loescht es, ein Text setzt es neu. */
|
||||
readonly smtpPasswort?: string | null;
|
||||
readonly smtpAbsender?: string | null;
|
||||
readonly webhookUrl?: string | null;
|
||||
readonly webhookGeheimnis?: string | null;
|
||||
};
|
||||
|
||||
function geputzt(wert: string | null | undefined): string | null | undefined {
|
||||
if (wert === undefined) return undefined;
|
||||
if (wert === null) return null;
|
||||
return wert.trim() || null;
|
||||
}
|
||||
|
||||
export async function meldungseinstellungenSpeichern(
|
||||
aenderung: MeldungsAenderung,
|
||||
durchBenutzerId: string,
|
||||
): Promise<void> {
|
||||
const host = geputzt(aenderung.smtpHost);
|
||||
if (host && aenderung.smtpPort === null) {
|
||||
throw new MeldungsFehler("Zu einem SMTP-Host gehört ein Port.");
|
||||
}
|
||||
if (aenderung.smtpPort !== undefined && aenderung.smtpPort !== null) {
|
||||
if (!Number.isInteger(aenderung.smtpPort) || aenderung.smtpPort < 1 || aenderung.smtpPort > 65535) {
|
||||
throw new MeldungsFehler("Der SMTP-Port muss zwischen 1 und 65535 liegen.");
|
||||
}
|
||||
}
|
||||
|
||||
const url = geputzt(aenderung.webhookUrl);
|
||||
if (url) {
|
||||
let geprueft: URL;
|
||||
try {
|
||||
geprueft = new URL(url);
|
||||
} catch {
|
||||
throw new MeldungsFehler("Die Webhook-Adresse ist keine gültige URL.");
|
||||
}
|
||||
if (geprueft.protocol !== "https:" && geprueft.protocol !== "http:") {
|
||||
throw new MeldungsFehler("Die Webhook-Adresse muss mit http:// oder https:// beginnen.");
|
||||
}
|
||||
}
|
||||
|
||||
const vorher = await meldungseinstellungenHolen();
|
||||
const passwort = geputzt(aenderung.smtpPasswort);
|
||||
const geheimnis = geputzt(aenderung.webhookGeheimnis);
|
||||
|
||||
const daten = {
|
||||
...(host !== undefined ? { smtpHost: host } : {}),
|
||||
...(aenderung.smtpPort !== undefined ? { smtpPort: aenderung.smtpPort } : {}),
|
||||
...(aenderung.smtpTls !== undefined ? { smtpTls: aenderung.smtpTls } : {}),
|
||||
...(geputzt(aenderung.smtpBenutzer) !== undefined
|
||||
? { smtpBenutzer: geputzt(aenderung.smtpBenutzer) }
|
||||
: {}),
|
||||
...(geputzt(aenderung.smtpAbsender) !== undefined
|
||||
? { smtpAbsender: geputzt(aenderung.smtpAbsender) }
|
||||
: {}),
|
||||
...(passwort !== undefined
|
||||
? { smtpPasswortVerschluesselt: passwort === null ? null : verschluesseln(passwort) }
|
||||
: {}),
|
||||
...(url !== undefined ? { webhookUrl: url } : {}),
|
||||
...(geheimnis !== undefined
|
||||
? { webhookGeheimnisVerschluesselt: geheimnis === null ? null : verschluesseln(geheimnis) }
|
||||
: {}),
|
||||
};
|
||||
if (Object.keys(daten).length === 0) return;
|
||||
|
||||
await prisma.einstellung.upsert({
|
||||
where: { id: "global" },
|
||||
create: { id: "global", ...daten },
|
||||
update: daten,
|
||||
});
|
||||
|
||||
/*
|
||||
* Ins Protokoll gehen **Namen, nicht Werte**. Ein Protokolleintrag mit dem
|
||||
* SMTP-Passwort waere genau die Stelle, an der die Verschluesselung sinnlos
|
||||
* wird — und das Protokoll ist ansehbar (#023).
|
||||
*/
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Einstellung",
|
||||
entitaetId: "global",
|
||||
aktion: "Benachrichtigungen geändert",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: {
|
||||
smtpHost: vorher.smtp.host,
|
||||
smtpPort: vorher.smtp.port,
|
||||
smtpTls: vorher.smtp.tls,
|
||||
smtpBenutzer: vorher.smtp.benutzer,
|
||||
smtpAbsender: vorher.smtp.absender,
|
||||
smtpPasswortGesetzt: vorher.smtp.passwortGesetzt,
|
||||
webhookUrl: vorher.webhook.url,
|
||||
webhookGeheimnisGesetzt: vorher.webhook.geheimnisGesetzt,
|
||||
},
|
||||
nachher: { geaenderteFelder: Object.keys(daten) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Versand */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type SmtpZugang = {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly tls: boolean;
|
||||
readonly benutzer: string | null;
|
||||
readonly passwort: string | null;
|
||||
readonly absender: string;
|
||||
};
|
||||
|
||||
/** Der SMTP-Zugang aus der Datenbank, entschluesselt. `undefined` = nicht eingerichtet. */
|
||||
async function smtpZugang(): Promise<SmtpZugang | undefined> {
|
||||
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
|
||||
if (!zeile?.smtpHost || !zeile.smtpPort) return undefined;
|
||||
return {
|
||||
host: zeile.smtpHost,
|
||||
port: zeile.smtpPort,
|
||||
tls: zeile.smtpTls,
|
||||
benutzer: zeile.smtpBenutzer,
|
||||
passwort: zeile.smtpPasswortVerschluesselt ? entschluesseln(zeile.smtpPasswortVerschluesselt) : null,
|
||||
// Ohne eigenen Absender der Benutzername, sonst ein neutraler Name am Host.
|
||||
absender: zeile.smtpAbsender ?? zeile.smtpBenutzer ?? `laufzettel@${zeile.smtpHost}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function webhookZugang(): Promise<{ url: string; geheimnis: string | null } | undefined> {
|
||||
const zeile = await prisma.einstellung.findUnique({ where: { id: "global" } });
|
||||
if (!zeile?.webhookUrl) return undefined;
|
||||
return {
|
||||
url: zeile.webhookUrl,
|
||||
geheimnis: zeile.webhookGeheimnisVerschluesselt
|
||||
? entschluesseln(zeile.webhookGeheimnisVerschluesselt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Mail verschicken.
|
||||
*
|
||||
* `secure` nur auf 465 — das ist der Port fuer implizites TLS. Auf 587 und 25
|
||||
* wird mit `requireTLS` auf StartTLS bestanden, sofern TLS verlangt ist. Ein
|
||||
* `secure: true` auf 587 laeuft in einen Zeitablauf, und die Meldung dazu
|
||||
* („greeting never received“) sagt nichts ueber die Ursache.
|
||||
*/
|
||||
async function mailSenden(zugang: SmtpZugang, an: string, betreff: string, text: string): Promise<void> {
|
||||
const transport = createTransport({
|
||||
host: zugang.host,
|
||||
port: zugang.port,
|
||||
secure: zugang.port === 465,
|
||||
requireTLS: zugang.tls && zugang.port !== 465,
|
||||
ignoreTLS: !zugang.tls,
|
||||
...(zugang.benutzer ? { auth: { user: zugang.benutzer, pass: zugang.passwort ?? "" } } : {}),
|
||||
// Ein haengender Mailserver darf den Durchgang nicht aufhalten.
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
});
|
||||
try {
|
||||
await transport.sendMail({ from: zugang.absender, to: an, subject: betreff, text });
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Einen Webhook abschicken – mit Signatur und Zeitstempel. */
|
||||
async function webhookSenden(url: string, geheimnis: string | null, rumpf: string): Promise<void> {
|
||||
const sekunden = Math.floor(Date.now() / 1000);
|
||||
const abbruch = AbortSignal.timeout(15_000);
|
||||
const antwort = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(geheimnis ? { [SIGNATUR_KOPF]: signaturBauen(geheimnis, rumpf, sekunden) } : {}),
|
||||
},
|
||||
body: rumpf,
|
||||
signal: abbruch,
|
||||
});
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Die Gegenseite antwortete mit ${antwort.status} ${antwort.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Testmail, direkt und ohne Warteschlange.
|
||||
*
|
||||
* Mit Warteschlange waere der Knopf „Testmail schicken“ wertlos: er wuerde
|
||||
* „eingereiht“ melden, und ob es klappt, saehe man eine Minute spaeter
|
||||
* woanders. Hier soll der Fehler **sofort** und im Klartext erscheinen.
|
||||
*/
|
||||
export async function testmailSenden(an: string): Promise<void> {
|
||||
const zugang = await smtpZugang();
|
||||
if (!zugang) throw new MeldungsFehler("Es ist kein SMTP-Server eingerichtet.");
|
||||
const ziel = an.trim();
|
||||
if (!ziel.includes("@")) throw new MeldungsFehler("Das ist keine Mailadresse.");
|
||||
|
||||
try {
|
||||
await mailSenden(
|
||||
zugang,
|
||||
ziel,
|
||||
"Laufzettel: Testmail",
|
||||
"Wenn diese Nachricht angekommen ist, kann Laufzettel Mail verschicken.\n\n" +
|
||||
`Server: ${zugang.host}:${zugang.port}${zugang.tls ? " mit TLS" : " ohne TLS"}\n` +
|
||||
`Absender: ${zugang.absender}`,
|
||||
);
|
||||
} catch (fehler) {
|
||||
throw new MeldungsFehler(
|
||||
`Die Testmail ging nicht hinaus: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Einreihen */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function appUrl(): string {
|
||||
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function laufLink(laufId: string): string {
|
||||
return `${appUrl()}/laeufe/${laufId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Empfaenger einer Lauf-Meldung.
|
||||
*
|
||||
* Wer zugewiesen ist, und bei einem Abschluss zusaetzlich, wer den Lauf angelegt
|
||||
* hat. **Nie der Handelnde selbst** — eine Mail darueber, was man gerade eben
|
||||
* geklickt hat, ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten.
|
||||
*
|
||||
* Und: **die Mandantengrenze gilt auch hier.** Ein Benutzer, der den Kunden des
|
||||
* Laufs nicht sehen darf, bekommt keine Mail mit dessen Lauftitel und Geraet.
|
||||
* Ohne diese Pruefung waere die Benachrichtigung der eine Weg nach draussen, den
|
||||
* `sichtbareKundenIds` nicht abdeckt.
|
||||
*/
|
||||
async function laufEmpfaenger(
|
||||
lauf: {
|
||||
readonly id: string;
|
||||
readonly kundeId: string;
|
||||
readonly zugewiesenAnId: string | null;
|
||||
readonly erstelltVonId: string | null;
|
||||
},
|
||||
anlass: LaufAnlass,
|
||||
handelnderId: string | null,
|
||||
): Promise<{ id: string; email: string; name: string }[]> {
|
||||
const kennungen = new Set<string>();
|
||||
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
|
||||
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
|
||||
kennungen.add(lauf.erstelltVonId);
|
||||
}
|
||||
if (handelnderId) kennungen.delete(handelnderId);
|
||||
if (kennungen.size === 0) return [];
|
||||
|
||||
const kandidaten = await prisma.benutzer.findMany({
|
||||
where: { id: { in: [...kennungen] }, aktiv: true, meldungenAktiv: true },
|
||||
select: { id: true, email: true, name: true, rolle: true },
|
||||
});
|
||||
|
||||
const erlaubt: { id: string; email: string; name: string }[] = [];
|
||||
for (const person of kandidaten) {
|
||||
const sichtbar = await sichtbareKundenIds({ id: person.id, rolle: person.rolle });
|
||||
if (sichtbar === undefined || sichtbar.includes(lauf.kundeId)) {
|
||||
erlaubt.push({ id: person.id, email: person.email, name: person.name });
|
||||
}
|
||||
}
|
||||
return erlaubt;
|
||||
}
|
||||
|
||||
/** Eine Zeile je Empfaenger, und eine fuer den Webhook. */
|
||||
async function einreihen(
|
||||
anlass: Meldeanlass,
|
||||
betreff: string,
|
||||
rumpf: string,
|
||||
link: string,
|
||||
bezug: { readonly laufId?: string; readonly verzeichnisquelleId?: string },
|
||||
empfaenger: readonly { readonly id: string; readonly email: string }[],
|
||||
): Promise<number> {
|
||||
// Ausdruecklich typisiert: aus dem Literal allein leitete TypeScript
|
||||
// `weg: "MAIL"` und `benutzerId: string` ab, und die Webhook-Zeile darunter
|
||||
// passte dann nicht mehr hinein.
|
||||
const zeilen: Prisma.ZustellungCreateManyInput[] = empfaenger.map((person) => ({
|
||||
anlass,
|
||||
weg: Zustellweg.MAIL,
|
||||
empfaenger: person.email,
|
||||
betreff,
|
||||
rumpf,
|
||||
benutzerId: person.id,
|
||||
laufId: bezug.laufId ?? null,
|
||||
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
|
||||
}));
|
||||
|
||||
const webhook = await webhookZugang();
|
||||
if (webhook) {
|
||||
zeilen.push({
|
||||
anlass,
|
||||
weg: Zustellweg.WEBHOOK,
|
||||
empfaenger: webhook.url,
|
||||
betreff,
|
||||
rumpf: webhookRumpf(anlass, betreff, link, bezug, new Date()),
|
||||
// Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar.
|
||||
benutzerId: null,
|
||||
laufId: bezug.laufId ?? null,
|
||||
verzeichnisquelleId: bezug.verzeichnisquelleId ?? null,
|
||||
});
|
||||
}
|
||||
if (zeilen.length === 0) return 0;
|
||||
|
||||
/*
|
||||
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
|
||||
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
|
||||
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
|
||||
*/
|
||||
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
|
||||
return ergebnis.count;
|
||||
}
|
||||
|
||||
/** Meldung zu einem Lauf einreihen. Gibt die Zahl der erzeugten Zeilen zurueck. */
|
||||
export async function laufMeldungEinreihen(
|
||||
anlass: LaufAnlass,
|
||||
laufId: string,
|
||||
handelnderId: string | null = null,
|
||||
): Promise<number> {
|
||||
const lauf = await prisma.lauf.findUnique({
|
||||
where: { id: laufId },
|
||||
select: {
|
||||
id: true,
|
||||
kundeId: true,
|
||||
titel: true,
|
||||
faelligAm: true,
|
||||
zugewiesenAnId: true,
|
||||
erstelltVonId: true,
|
||||
mitarbeiterName: true,
|
||||
mitarbeiter: { select: { anzeigename: true } },
|
||||
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
||||
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!lauf) return 0;
|
||||
|
||||
const empfaenger = await laufEmpfaenger(lauf, anlass, handelnderId);
|
||||
const webhook = await webhookZugang();
|
||||
// Niemand da und kein Webhook: dann auch keine Zeile, die nie ankommt.
|
||||
if (empfaenger.length === 0 && !webhook) return 0;
|
||||
|
||||
const handelnder = handelnderId
|
||||
? await prisma.benutzer.findUnique({ where: { id: handelnderId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
const { betreff, rumpf } = laufMeldung(
|
||||
anlass,
|
||||
{
|
||||
titel: lauf.titel,
|
||||
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
|
||||
person: lauf.mitarbeiter?.anzeigename ?? lauf.mitarbeiterName,
|
||||
faelligAm: lauf.faelligAm,
|
||||
vorlage: lauf.vorlagenVersion.vorlage.name,
|
||||
handelnder: handelnder?.name ?? null,
|
||||
},
|
||||
laufLink(lauf.id),
|
||||
);
|
||||
|
||||
return einreihen(anlass, betreff, rumpf, laufLink(lauf.id), { laufId: lauf.id }, empfaenger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung zu einem fehlgeschlagenen Abgleich einreihen.
|
||||
*
|
||||
* Empfaenger sind **alle aktiven Administratoren**: eine Verzeichnisquelle
|
||||
* gehoert niemandem persoenlich, und wer sie eingerichtet hat, ist vielleicht
|
||||
* nicht mehr im Haus.
|
||||
*/
|
||||
export async function abgleichMeldungEinreihen(quelleId: string, grund: string): Promise<number> {
|
||||
const quelle = await prisma.verzeichnisquelle.findUnique({
|
||||
where: { id: quelleId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!quelle) return 0;
|
||||
|
||||
const admins = await prisma.benutzer.findMany({
|
||||
where: { rolle: "ADMIN", aktiv: true, meldungenAktiv: true },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
const webhook = await webhookZugang();
|
||||
if (admins.length === 0 && !webhook) return 0;
|
||||
|
||||
const link = `${appUrl()}/verwaltung/verzeichnis`;
|
||||
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
|
||||
return einreihen(
|
||||
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
|
||||
betreff,
|
||||
rumpf,
|
||||
link,
|
||||
{ verzeichnisquelleId: quelle.id },
|
||||
admins,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ueberfaellige Laeufe einreihen – fuer den Zeitplan.
|
||||
*
|
||||
* Faellig **und** nicht vom Tisch. Der eindeutige Index sorgt dafuer, dass das
|
||||
* je Lauf und Empfaenger genau einmal passiert, auch wenn der Zeitplan jede
|
||||
* Minute nachsieht.
|
||||
*/
|
||||
export async function ueberfaelligeEinreihen(jetzt = new Date()): Promise<number> {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: {
|
||||
faelligAm: { lt: jetzt },
|
||||
status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] },
|
||||
zugewiesenAnId: { not: null },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let erzeugt = 0;
|
||||
for (const lauf of laeufe) {
|
||||
erzeugt += await laufMeldungEinreihen(Meldeanlass.LAUF_UEBERFAELLIG, lauf.id);
|
||||
}
|
||||
return erzeugt;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Abarbeiten */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type DurchgangErgebnis = {
|
||||
readonly gesendet: number;
|
||||
readonly fehlgeschlagen: number;
|
||||
readonly aufgegeben: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was nach einem Fehlschlag mit der Zeile passiert.
|
||||
*
|
||||
* `versucheGesamt` zaehlt **einschliesslich** des gerade gescheiterten: nach dem
|
||||
* ersten Fehlschlag wird eine Minute gewartet, nach dem zweiten fuenf, dann
|
||||
* dreissig, dann zwei Stunden — und nach dem fuenften ist Schluss. Vier
|
||||
* Wartezeiten ergeben also fuenf Versuche.
|
||||
*
|
||||
* Eine reine Funktion, damit sich die Wiederholungsregel pruefen laesst, ohne
|
||||
* einen Mailserver zu brauchen oder zwei Stunden zu warten.
|
||||
*/
|
||||
export function naechsterVersuch(
|
||||
versucheGesamt: number,
|
||||
jetzt: Date,
|
||||
): { readonly zustand: Zustellzustand; readonly naechsterVersuchAm: Date } {
|
||||
const wartezeit = WARTEZEITEN_MINUTEN[versucheGesamt - 1];
|
||||
if (wartezeit === undefined) {
|
||||
/*
|
||||
* Aufgegeben — aber **sichtbar**. Eine stille Warteschlange ist schlimmer
|
||||
* als keine: niemand wuerde merken, dass seit Wochen keine Mail mehr
|
||||
* hinausgeht. Der Zeitpunkt bleibt stehen, damit die Liste die Reihenfolge
|
||||
* behaelt.
|
||||
*/
|
||||
return { zustand: Zustellzustand.AUFGEGEBEN, naechsterVersuchAm: jetzt };
|
||||
}
|
||||
return {
|
||||
zustand: Zustellzustand.FEHLGESCHLAGEN,
|
||||
naechsterVersuchAm: new Date(jetzt.getTime() + wartezeit * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Durchgang der Warteschlange fahren.
|
||||
*
|
||||
* `undefined`, wenn ein anderer Durchgang laeuft — dieselbe Sperre wie beim
|
||||
* Abgleich, in der Datenbank und damit auch ueber zwei Instanzen hinweg. Ohne
|
||||
* sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal.
|
||||
*/
|
||||
export async function warteschlangeAbarbeiten(jetzt = new Date()): Promise<DurchgangErgebnis | undefined> {
|
||||
return mitSperre(WARTESCHLANGE_SCHLUESSEL, SPERRE_MINUTEN, async () => {
|
||||
const offene = await prisma.zustellung.findMany({
|
||||
where: {
|
||||
zustand: { in: [Zustellzustand.OFFEN, Zustellzustand.FEHLGESCHLAGEN] },
|
||||
naechsterVersuchAm: { lte: jetzt },
|
||||
},
|
||||
orderBy: { naechsterVersuchAm: "asc" },
|
||||
take: BUENDEL,
|
||||
});
|
||||
|
||||
let gesendet = 0;
|
||||
let fehlgeschlagen = 0;
|
||||
let aufgegeben = 0;
|
||||
|
||||
// Die Zugaenge einmal je Durchgang, nicht je Zeile.
|
||||
const smtp = await smtpZugang();
|
||||
const webhook = await webhookZugang();
|
||||
|
||||
for (const zeile of offene) {
|
||||
try {
|
||||
if (zeile.weg === Zustellweg.MAIL) {
|
||||
if (!smtp) throw new Error("Es ist kein SMTP-Server eingerichtet.");
|
||||
await mailSenden(smtp, zeile.empfaenger, zeile.betreff, zeile.rumpf);
|
||||
} else {
|
||||
if (!webhook) throw new Error("Es ist kein Webhook eingerichtet.");
|
||||
await webhookSenden(webhook.url, webhook.geheimnis, zeile.rumpf);
|
||||
}
|
||||
await prisma.zustellung.update({
|
||||
where: { id: zeile.id },
|
||||
data: {
|
||||
zustand: Zustellzustand.GESENDET,
|
||||
gesendetAm: new Date(),
|
||||
versuche: zeile.versuche + 1,
|
||||
letzterFehler: null,
|
||||
},
|
||||
});
|
||||
gesendet += 1;
|
||||
} catch (fehler) {
|
||||
const naechster = naechsterVersuch(zeile.versuche + 1, jetzt);
|
||||
await prisma.zustellung.update({
|
||||
where: { id: zeile.id },
|
||||
data: {
|
||||
zustand: naechster.zustand,
|
||||
naechsterVersuchAm: naechster.naechsterVersuchAm,
|
||||
versuche: zeile.versuche + 1,
|
||||
// Gekappt: eine TLS-Fehlermeldung kann Kilobyte lang sein.
|
||||
letzterFehler: (fehler instanceof Error ? fehler.message : "unbekannter Fehler").slice(0, 500),
|
||||
},
|
||||
});
|
||||
if (naechster.zustand === Zustellzustand.AUFGEGEBEN) aufgegeben += 1;
|
||||
else fehlgeschlagen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { gesendet, fehlgeschlagen, aufgegeben };
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Prisma, type Lauf } from "@/generated/prisma/client";
|
||||
import { 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Eine Sperre in der Datenbank, ueber Prozesse und Instanzen hinweg (#016, #034).
|
||||
*
|
||||
* Erst war sie Teil des Abgleichs; mit der Zustell-Warteschlange braucht sie ein
|
||||
* zweiter Aufrufer, und zwei Fassungen derselben Mechanik waere die schlechteste
|
||||
* aller Loesungen. Die Tabelle heisst weiter `AbgleichSperre` — ein Umbenennen
|
||||
* waere eine Migration, die nichts verbessert, und der Schluessel sagt ohnehin,
|
||||
* wer gerade sperrt (`abgleich:<quelleId>`, `zustellung:warteschlange`).
|
||||
*
|
||||
* **In der Datenbank und nicht im Prozess**, weil hinter einem Lastverteiler
|
||||
* zwei Instanzen laufen koennen. Ein `let laeuft = false` im Modul wuesste nichts
|
||||
* von der zweiten.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
|
||||
*
|
||||
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Durchgaenge im
|
||||
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
|
||||
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
|
||||
*/
|
||||
function haltername(): string {
|
||||
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
|
||||
*
|
||||
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
|
||||
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Durchgang, den
|
||||
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
|
||||
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
|
||||
* dauerhaft blockiert.
|
||||
*/
|
||||
export async function mitSperre<T>(
|
||||
schluessel: string,
|
||||
dauerMinuten: number,
|
||||
was: () => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const halter = haltername();
|
||||
const bekommen = await prisma.$executeRaw`
|
||||
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
|
||||
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
|
||||
ON CONFLICT ("schluessel") DO UPDATE
|
||||
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
|
||||
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
|
||||
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
|
||||
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
|
||||
`;
|
||||
if (bekommen === 0) return undefined;
|
||||
|
||||
try {
|
||||
return await was();
|
||||
} finally {
|
||||
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
|
||||
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
|
||||
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import cron, { type ScheduledTask } from "node-cron";
|
||||
import { ueberfaelligeEinreihen, warteschlangeAbarbeiten } from "@/server/services/benachrichtigungen";
|
||||
|
||||
/**
|
||||
* Der Zeitplan der Benachrichtigungen (#034).
|
||||
*
|
||||
* **Im selben Prozess** wie die Anwendung und wie der Abgleich-Zeitplan — kein
|
||||
* zweiter Dienst, aus demselben Grund wie dort: beide Betriebswege dieses
|
||||
* Projekts starten genau einen Prozess, und eine zweite Einheit waere eine
|
||||
* zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL` hinterlegen muss.
|
||||
*
|
||||
* Ein eigener Zeitplan und nicht derselbe wie der Abgleich: der eine darf nicht
|
||||
* warten, weil der andere gerade ein grosses Verzeichnis liest. Zwei Takte sind
|
||||
* billig, ein verzoegerter Mailversand nicht.
|
||||
*
|
||||
* Drei Dinge halten das zusammen:
|
||||
*
|
||||
* 1. **Die Sperre setzt der Dienst, nicht der Zeitplan.**
|
||||
* `warteschlangeAbarbeiten` nimmt sie selbst — in der Datenbank und damit
|
||||
* auch ueber zwei Instanzen hinweg. Eine zweite Sperre hier waere schlimmer
|
||||
* als keine: sie truege einen anderen Schluessel und liefe an der ersten
|
||||
* vorbei.
|
||||
* 2. **Ein Fehler beendet den Zeitplan nicht.** Ein Mailserver, der gerade nicht
|
||||
* antwortet, ist der Normalfall im Betrieb; was passiert ist, steht an der
|
||||
* Zustellung.
|
||||
* 3. **Ueberfaelligkeit wird jede Minute geprueft, aber je Lauf nur einmal
|
||||
* gemeldet.** Dafuer sorgt der partielle eindeutige Index
|
||||
* `Zustellung_ueberfaellig_einmal` — ohne ihn bekaeme der Zustaendige fuer
|
||||
* einen Lauf, der ueber das Wochenende steht, 2880 Mails.
|
||||
*/
|
||||
|
||||
/** Jede Minute. Was wirklich faellig ist, entscheidet die Datenbank. */
|
||||
const TAKT = "* * * * *";
|
||||
|
||||
let plan: ScheduledTask | undefined;
|
||||
|
||||
/**
|
||||
* Ein Durchgang: ueberfaellige einreihen, dann die Warteschlange abarbeiten.
|
||||
*
|
||||
* In dieser Reihenfolge, damit eine gerade erkannte Ueberfaelligkeit nicht erst
|
||||
* eine Minute spaeter hinausgeht. Exportiert, damit ein Test den Durchgang
|
||||
* aufrufen kann, ohne eine Minute zu warten.
|
||||
*/
|
||||
export async function meldungsDurchgang(): Promise<void> {
|
||||
try {
|
||||
await ueberfaelligeEinreihen();
|
||||
} catch (fehler) {
|
||||
console.error(
|
||||
"Überfällige Läufe konnten nicht eingereiht werden:",
|
||||
fehler instanceof Error ? fehler.message : fehler,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* `undefined` heisst: eine andere Instanz ist dran. Kein Fehler und keine
|
||||
* Zeile im Protokoll — im Normalbetrieb mit zwei Instanzen waere das jede
|
||||
* Minute eine.
|
||||
*/
|
||||
await warteschlangeAbarbeiten();
|
||||
} catch (fehler) {
|
||||
console.error(
|
||||
"Die Zustell-Warteschlange konnte nicht abgearbeitet werden:",
|
||||
fehler instanceof Error ? fehler.message : fehler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function meldungsplanStarten(): void {
|
||||
if (plan) return;
|
||||
plan = cron.schedule(TAKT, () => {
|
||||
void meldungsDurchgang();
|
||||
});
|
||||
}
|
||||
|
||||
export function meldungsplanAnhalten(): void {
|
||||
plan?.stop();
|
||||
plan = undefined;
|
||||
}
|
||||
|
||||
export function meldungsplanLaeuft(): boolean {
|
||||
return plan !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user