Merge branch 'phase/028-benachrichtigungen'
# Conflicts: # docker-compose.yml # scripts/screenshots.ts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user