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