Merge branch 'phase/028-benachrichtigungen'

# Conflicts:
#	docker-compose.yml
#	scripts/screenshots.ts
This commit is contained in:
2026-10-12 16:55:57 +02:00
31 changed files with 3397 additions and 16 deletions
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import { empfaengerAus, type BenachrichtigungEingabe } from "@/lib/benachrichtigung/einstellungen";
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
import {
akteurVon,
fehlerZustand,
listenfeld,
textfeld,
type FormularZustand,
} from "@/server/actions/formular";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { benachrichtigungSpeichern, testAnfordern } from "@/server/services/benachrichtigung";
import { FachFehler } from "@/server/services/fachfehler";
/** Einstellungen speichern und Testnachrichten anstossen (#028). Nur `einstellungenVerwalten`. */
function felder(formular: FormData): BenachrichtigungEingabe {
const ja = (name: string) => formular.get(name) === "ja";
return {
emailAktiv: ja("emailAktiv"),
emailServer: textfeld(formular, "emailServer") ?? "",
emailPort: textfeld(formular, "emailPort") ?? "",
emailSicherheit: (textfeld(formular, "emailSicherheit") ??
"STARTTLS") as BenachrichtigungEingabe["emailSicherheit"],
emailAbsender: textfeld(formular, "emailAbsender") ?? "",
emailBenutzer: textfeld(formular, "emailBenutzer") ?? "",
emailPasswort: textfeld(formular, "emailPasswort"),
emailPasswortEntfernen: ja("emailPasswortEntfernen"),
emailEmpfaenger: empfaengerAus(textfeld(formular, "emailEmpfaenger") ?? ""),
webhookAktiv: ja("webhookAktiv"),
webhookUrl: textfeld(formular, "webhookUrl") ?? "",
webhookHttpErlaubt: ja("webhookHttpErlaubt"),
webhookGeheimnis: textfeld(formular, "webhookGeheimnis"),
webhookGeheimnisEntfernen: ja("webhookGeheimnisEntfernen"),
anlaesse: listenfeld(formular, "anlaesse").filter((a): a is AnlassArt =>
(ANLAESSE as readonly string[]).includes(a),
),
};
}
export async function benachrichtigungSpeichernAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand & { gespeichert?: boolean }> {
try {
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
await benachrichtigungSpeichern(felder(formular), akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/einstellungen");
return { gespeichert: true };
}
export async function testSendenAktion(kanal: string): Promise<{ vorgangId?: string; fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
if (kanal !== "EMAIL" && kanal !== "WEBHOOK") return { fehler: "Unbekannter Kanal." };
return { vorgangId: await testAnfordern(kanal, benutzer.id) };
} catch (fehler) {
if (fehler instanceof RechteFehler || fehler instanceof FachFehler) return { fehler: fehler.message };
throw fehler;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { alsAnsicht, type BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
import { cronBeschreiben, webhookZielKurz } from "@/lib/benachrichtigung/regeln";
import { prisma } from "@/lib/db";
import { VERSION } from "@/lib/version";
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
/**
* Lesemodell der Einstellungsseite (#028). Lesen duerfen alle Rollen; Geheimnisse verlassen
* diese Datei nur als „gesetzt ja/nein“ (`alsAnsicht`).
*/
export type ZustellungZeile = {
readonly sendung: string;
readonly zeitpunkt: Date;
readonly kanal: "EMAIL" | "WEBHOOK";
readonly betreff: string;
readonly anlaesse: number;
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
readonly versuche: number;
readonly fehler: string | null;
readonly naechsterVersuchAm: Date | null;
};
export type Zeitplan = { readonly ausdruck: string; readonly beschreibung: string | null };
export type EinstellungenSeite = {
readonly benachrichtigung: BenachrichtigungAnsicht;
readonly webhookZiel: string | null;
readonly zustellungen: readonly ZustellungZeile[];
readonly abgleich: Zeitplan;
readonly meldung: Zeitplan;
readonly instanz: {
readonly version: string;
readonly deploymentKey: { readonly name: string; readonly fingerprint: string } | null;
readonly oidc: { readonly name: string; readonly konfiguriert: boolean };
readonly notfall: { readonly vorhanden: boolean; readonly totpEingerichtet: boolean };
};
};
function zeitplan(variable: string, standard: string): Zeitplan {
const ausdruck = process.env[variable]?.trim() || standard;
return { ausdruck, beschreibung: cronBeschreiben(ausdruck) };
}
/** Je Nachricht eine Zeile: alle Anlaesse einer Sendung teilen Kennung, Betreff und Ergebnis. */
export async function letzteZustellungen(anzahl = 12): Promise<ZustellungZeile[]> {
const zeilen = await prisma.zustellung.findMany({
orderBy: { zeitpunkt: "desc" },
take: 500,
select: {
sendung: true,
zeitpunkt: true,
kanal: true,
betreff: true,
ergebnis: true,
versuche: true,
fehler: true,
naechsterVersuchAm: true,
},
});
const gruppen = new Map<string, ZustellungZeile>();
for (const z of zeilen) {
const vorhanden = gruppen.get(z.sendung);
if (vorhanden) {
gruppen.set(z.sendung, {
...vorhanden,
anlaesse: vorhanden.anlaesse + 1,
versuche: Math.max(vorhanden.versuche, z.versuche),
});
} else {
if (gruppen.size >= anzahl) continue;
gruppen.set(z.sendung, { ...z, anlaesse: 1 });
}
}
return [...gruppen.values()];
}
export async function einstellungenSeite(): Promise<EinstellungenSeite> {
const [gespeichert, zustellungen, deploymentKey, notfall] = await Promise.all([
benachrichtigungLesen(),
letzteZustellungen(),
prisma.schluessel.findFirst({
where: { instanzStandard: true },
select: { name: true, fingerprint: true },
}),
prisma.benutzer.findFirst({ where: { istNotfall: true }, select: { totpEingerichtetAm: true } }),
]);
return {
benachrichtigung: alsAnsicht(gespeichert),
webhookZiel: gespeichert.webhook.url ? webhookZielKurz(gespeichert.webhook.url) : null,
zustellungen,
abgleich: zeitplan("SSHKV_SYNC_CRON", "30 2 * * *"),
meldung: zeitplan("SSHKV_MELDUNG_CRON", "0 7 * * *"),
instanz: {
version: VERSION,
deploymentKey,
oidc: { name: oidcName(), konfiguriert: oidcAktiv() },
notfall: { vorhanden: notfall !== null, totpEingerichtet: Boolean(notfall?.totpEingerichtetAm) },
},
};
}
+127
View File
@@ -0,0 +1,127 @@
import type { Prisma } from "@/generated/prisma/client";
import {
benachrichtigungSchema,
EINSTELLUNG_BENACHRICHTIGUNG,
gespeichertLesen,
type BenachrichtigungEingabe,
type GespeicherteBenachrichtigung,
} from "@/lib/benachrichtigung/einstellungen";
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse";
import { feldfehlerAus } from "@/lib/hosts";
import { FachFehler } from "@/server/services/fachfehler";
import type { Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Benachrichtigungs-Einstellungen lesen und speichern (#028). Geheimnisse werden hier
* verschluesselt und verlassen diese Datei nie im Klartext – entschluesselt wird nur beim
* Versand im Worker.
*/
/** Gespeicherter Stand inkl. Chiffraten – nur fuer Service und Worker, nie fuer die Oberflaeche. */
export async function benachrichtigungLesen(): Promise<GespeicherteBenachrichtigung> {
const eintrag = await prisma.einstellung.findUnique({
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
});
return gespeichertLesen(eintrag?.wert);
}
export async function benachrichtigungSpeichern(
eingabe: BenachrichtigungEingabe,
akteur: Akteur,
): Promise<void> {
const geprueft = benachrichtigungSchema.safeParse(eingabe);
if (!geprueft.success) {
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(geprueft.error));
}
const e = geprueft.data;
const vorher = await benachrichtigungLesen();
// Leeres Feld heisst „unveraendert“ – die Oberflaeche kennt das alte Geheimnis ja nicht.
const passwortNeu = e.emailPasswort ? e.emailPasswort : undefined;
const geheimnisNeu = e.webhookGeheimnis ? e.webhookGeheimnis : undefined;
const passwort = e.emailPasswortEntfernen
? null
: passwortNeu !== undefined
? verschluesseln(passwortNeu)
: vorher.email.passwort;
const geheimnis = e.webhookGeheimnisEntfernen
? null
: geheimnisNeu !== undefined
? verschluesseln(geheimnisNeu)
: vorher.webhook.geheimnis;
const anlaesse = Object.fromEntries(ANLAESSE.map((a) => [a, e.anlaesse.includes(a)])) as Record<
AnlassArt,
boolean
>;
const neu: GespeicherteBenachrichtigung = {
email: {
aktiv: e.emailAktiv,
server: e.emailServer,
port: e.emailPort,
sicherheit: e.emailSicherheit,
absender: e.emailAbsender,
benutzer: e.emailBenutzer,
passwort,
empfaenger: e.emailEmpfaenger,
},
webhook: { aktiv: e.webhookAktiv, url: e.webhookUrl, httpErlaubt: e.webhookHttpErlaubt, geheimnis },
anlaesse,
};
await prisma.$transaction(async (tx) => {
await tx.einstellung.upsert({
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
create: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG, wert: neu as unknown as Prisma.InputJsonValue },
update: { wert: neu as unknown as Prisma.InputJsonValue },
});
await protokollieren(
{
akteur,
aktion: "einstellungen.benachrichtigungen.geaendert",
ergebnis: "ERFOLG",
// Nur, ob sich ein Geheimnis geaendert hat – nie der Wert, auch nicht verschluesselt. Die
// Namen meiden „passwort“/„geheimnis“, sonst schwaerzt schwaerzen() schon den Wahrheitswert.
details: {
emailAktiv: neu.email.aktiv,
smtpServer: neu.email.server,
empfaenger: neu.email.empfaenger.length,
webhookAktiv: neu.webhook.aktiv,
webhookHttp: neu.webhook.httpErlaubt,
smtpZugangGeaendert: passwort !== vorher.email.passwort,
signaturSchluesselGeaendert: geheimnis !== vorher.webhook.geheimnis,
anlaesse: ANLAESSE.filter((a) => anlaesse[a]),
},
},
tx,
);
});
}
/** Ist ein Kanal so weit eingetragen, dass ein Versand ueberhaupt Sinn hat? */
export function kanalVollstaendig(e: GespeicherteBenachrichtigung, kanal: "EMAIL" | "WEBHOOK"): boolean {
if (kanal === "EMAIL") return Boolean(e.email.server && e.email.absender && e.email.empfaenger.length > 0);
return Boolean(e.webhook.url);
}
/**
* Testnachricht als Vorgang im Worker (#028): dort liegt auch der regulaere Versand, und der
* Webprozess braucht so keinen Weg zu Mailserver oder Webhook-Ziel. Getestet wird der
* gespeicherte Stand – auch wenn der Kanal noch ausgeschaltet ist, damit man vor dem
* Einschalten pruefen kann.
*/
export async function testAnfordern(kanal: "EMAIL" | "WEBHOOK", benutzerId: string): Promise<string> {
const einstellungen = await benachrichtigungLesen();
if (!kanalVollstaendig(einstellungen, kanal)) {
throw new FachFehler(
kanal === "EMAIL"
? "Erst Server, Absender und Empfänger speichern."
: "Erst eine Webhook-Adresse speichern.",
);
}
return vorgangEinreihen({ art: "BENACHRICHTIGUNG_TEST", angefordertVonId: benutzerId, eingabe: { kanal } });
}
+3
View File
@@ -10,6 +10,9 @@ import { PgBoss } from "pg-boss";
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
export const WARTESCHLANGE = "sshkv-vorgang";
export const ZEITPLAN_ABGLEICH = "sshkv-abgleich-zeitplan";
/** Taegliche Zusammenfassung und Fuenf-Minuten-Takt der Benachrichtigungen (#028). */
export const ZEITPLAN_MELDUNG = "sshkv-meldung-zeitplan";
export const ZEITPLAN_MELDUNG_TAKT = "sshkv-meldung-takt";
/** Was im Job steht. Absichtlich nur die ID: alles Weitere liegt im `Vorgang`. */
export type VorgangsJob = {