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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user