feat(alerts): Benachrichtigungen per E-Mail und Webhook (#012)
SMTP mit STARTTLS fuer Microsoft 365, Webhook mit HMAC-Signatur, taegliche oder woechentliche Zusammenfassung, Versandprotokoll und Testversand. Keine Doppelmeldungen dank Pruefung gegen das Protokoll. 134 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
|
||||
import { testNachricht } from "@/server/notifications/nachrichten";
|
||||
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
function neuLaden() {
|
||||
revalidatePath("/einstellungen/benachrichtigungen");
|
||||
}
|
||||
|
||||
function appUrl() {
|
||||
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Legt den E-Mail-Kanal an oder ändert ihn. Ein leeres Passwortfeld lässt das gespeicherte unangetastet. */
|
||||
export async function emailKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const host = formular.get("host")?.toString().trim() ?? "";
|
||||
const port = Number(formular.get("port") ?? 587);
|
||||
const empfaenger = (formular.get("empfaenger")?.toString() ?? "")
|
||||
.split(/[,;\s]+/)
|
||||
.map((eintrag) => eintrag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!host) return { fehler: "Bitte den SMTP-Server angeben." };
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return { fehler: "Ungültiger Port." };
|
||||
if (empfaenger.length === 0) return { fehler: "Bitte mindestens einen Empfänger angeben." };
|
||||
|
||||
const bisher = id
|
||||
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
|
||||
EmailKonfiguration | undefined)
|
||||
: undefined;
|
||||
|
||||
const passwortEingabe = formular.get("passwort")?.toString() ?? "";
|
||||
|
||||
const konfiguration: EmailKonfiguration = {
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
benutzer: formular.get("benutzer")?.toString().trim() ?? "",
|
||||
// Leeres Feld heißt „unverändert“ – so muss das Passwort nicht erneut eingegeben werden.
|
||||
passwort: passwortEingabe || (bisher?.passwort ?? ""),
|
||||
absender: formular.get("absender")?.toString().trim() ?? "",
|
||||
empfaenger,
|
||||
};
|
||||
|
||||
const daten = {
|
||||
typ: "EMAIL" as const,
|
||||
name: formular.get("name")?.toString().trim() || "E-Mail",
|
||||
aktiv: formular.get("aktiv") === "on",
|
||||
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
|
||||
};
|
||||
|
||||
const kanal = id
|
||||
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
|
||||
: await prisma.benachrichtigungskanal.create({ data: daten });
|
||||
|
||||
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
|
||||
typ: "EMAIL",
|
||||
host,
|
||||
empfaenger,
|
||||
});
|
||||
|
||||
neuLaden();
|
||||
return { erfolg: "E-Mail-Versand gespeichert." };
|
||||
}
|
||||
|
||||
export async function webhookKanalSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const id = formular.get("id")?.toString() || null;
|
||||
const url = formular.get("url")?.toString().trim() ?? "";
|
||||
|
||||
if (!/^https?:\/\//.test(url)) return { fehler: "Bitte eine vollständige Adresse mit https:// angeben." };
|
||||
|
||||
const bisher = id
|
||||
? ((await prisma.benachrichtigungskanal.findUnique({ where: { id } }))?.konfiguration as unknown as
|
||||
WebhookKonfiguration | undefined)
|
||||
: undefined;
|
||||
|
||||
const geheimnisEingabe = formular.get("geheimnis")?.toString() ?? "";
|
||||
|
||||
const konfiguration: WebhookKonfiguration = {
|
||||
url,
|
||||
geheimnis: geheimnisEingabe || bisher?.geheimnis,
|
||||
};
|
||||
|
||||
const daten = {
|
||||
typ: "WEBHOOK" as const,
|
||||
name: formular.get("name")?.toString().trim() || "Webhook",
|
||||
aktiv: formular.get("aktiv") === "on",
|
||||
konfiguration: JSON.parse(JSON.stringify(konfiguration)),
|
||||
};
|
||||
|
||||
const kanal = id
|
||||
? await prisma.benachrichtigungskanal.update({ where: { id }, data: daten })
|
||||
: await prisma.benachrichtigungskanal.create({ data: daten });
|
||||
|
||||
await protokolliere("benachrichtigungskanal.gespeichert", "Benachrichtigungskanal", kanal.id, {
|
||||
typ: "WEBHOOK",
|
||||
url,
|
||||
});
|
||||
|
||||
neuLaden();
|
||||
return { erfolg: "Webhook gespeichert." };
|
||||
}
|
||||
|
||||
export async function kanalLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.benachrichtigungskanal.delete({ where: { id } });
|
||||
await protokolliere("benachrichtigungskanal.geloescht", "Benachrichtigungskanal", id, {});
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
/** Verschickt eine Testnachricht über einen Kanal. */
|
||||
export async function testSenden(formular: FormData): Promise<void> {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await ueberEinenKanalSenden(id, testNachricht(appUrl()));
|
||||
} catch (fehler) {
|
||||
// Das Ergebnis steht im Versandprotokoll auf der Seite – dort ist auch der Fehlertext zu sehen.
|
||||
console.error("Testversand fehlgeschlagen:", fehler);
|
||||
}
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
|
||||
export async function zusammenfassungSpeichern(formular: FormData) {
|
||||
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
|
||||
|
||||
await prisma.einstellung.upsert({
|
||||
where: { schluessel: "zusammenfassung" },
|
||||
update: { wert: { zeitraum } },
|
||||
create: { schluessel: "zusammenfassung", wert: { zeitraum } },
|
||||
});
|
||||
|
||||
await protokolliere("zusammenfassung.geaendert", "Einstellung", "zusammenfassung", { zeitraum });
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
|
||||
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
|
||||
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
|
||||
await zusammenfassungSenden(zeitraum);
|
||||
neuLaden();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import type { NotificationKanal } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Versandwege für Benachrichtigungen: SMTP (getestet gegen Microsoft 365) und ein generischer Webhook,
|
||||
* an den sich Matrix, Zabbix oder Home Assistant anbinden lassen.
|
||||
*/
|
||||
|
||||
export interface EmailKonfiguration {
|
||||
host: string;
|
||||
port: number;
|
||||
/** true = SMTPS (Port 465), false = STARTTLS (Port 587, so macht es Microsoft 365). */
|
||||
secure: boolean;
|
||||
benutzer: string;
|
||||
passwort: string;
|
||||
absender: string;
|
||||
empfaenger: string[];
|
||||
}
|
||||
|
||||
export interface WebhookKonfiguration {
|
||||
url: string;
|
||||
/** Optionales Geheimnis für die Signatur im Header `X-ProxLedger-Signatur`. */
|
||||
geheimnis?: string;
|
||||
kopfzeilen?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Nachricht {
|
||||
betreff: string;
|
||||
text: string;
|
||||
html: string;
|
||||
/** Strukturierte Fassung für den Webhook. */
|
||||
daten: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class VersandFehler extends Error {}
|
||||
|
||||
/** Verschickt eine Nachricht per SMTP. */
|
||||
export async function perEmailSenden(konfiguration: EmailKonfiguration, nachricht: Nachricht): Promise<void> {
|
||||
if (konfiguration.empfaenger.length === 0) {
|
||||
throw new VersandFehler("Kein Empfänger hinterlegt.");
|
||||
}
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: konfiguration.host,
|
||||
port: konfiguration.port,
|
||||
secure: konfiguration.secure,
|
||||
auth: konfiguration.benutzer ? { user: konfiguration.benutzer, pass: konfiguration.passwort } : undefined,
|
||||
// Microsoft 365 verlangt STARTTLS auf Port 587; `requireTLS` verhindert unverschlüsselten Versand.
|
||||
requireTLS: !konfiguration.secure,
|
||||
});
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: konfiguration.absender,
|
||||
to: konfiguration.empfaenger.join(", "),
|
||||
subject: nachricht.betreff,
|
||||
text: nachricht.text,
|
||||
html: nachricht.html,
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new VersandFehler(fehler instanceof Error ? fehler.message : "SMTP-Versand fehlgeschlagen.");
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Verschickt eine Nachricht an einen Webhook; bei Geheimnis mit HMAC-Signatur. */
|
||||
export async function perWebhookSenden(
|
||||
konfiguration: WebhookKonfiguration,
|
||||
nachricht: Nachricht,
|
||||
): Promise<void> {
|
||||
const koerper = JSON.stringify({
|
||||
betreff: nachricht.betreff,
|
||||
text: nachricht.text,
|
||||
...nachricht.daten,
|
||||
});
|
||||
|
||||
const kopfzeilen: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "ProxLedger",
|
||||
...konfiguration.kopfzeilen,
|
||||
};
|
||||
|
||||
if (konfiguration.geheimnis) {
|
||||
kopfzeilen["X-ProxLedger-Signatur"] = `sha256=${createHmac("sha256", konfiguration.geheimnis)
|
||||
.update(koerper)
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(konfiguration.url, { method: "POST", headers: kopfzeilen, body: koerper });
|
||||
} catch (fehler) {
|
||||
throw new VersandFehler(fehler instanceof Error ? fehler.message : "Webhook nicht erreichbar.");
|
||||
}
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new VersandFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface KanalEintrag {
|
||||
id: string;
|
||||
typ: NotificationKanal;
|
||||
name: string;
|
||||
aktiv: boolean;
|
||||
konfiguration: EmailKonfiguration | WebhookKonfiguration;
|
||||
}
|
||||
|
||||
export async function aktiveKanaele(): Promise<KanalEintrag[]> {
|
||||
const kanaele = await prisma.benachrichtigungskanal.findMany({ where: { aktiv: true } });
|
||||
return kanaele.map((kanal) => ({
|
||||
id: kanal.id,
|
||||
typ: kanal.typ,
|
||||
name: kanal.name,
|
||||
aktiv: kanal.aktiv,
|
||||
konfiguration: kanal.konfiguration as unknown as EmailKonfiguration | WebhookKonfiguration,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Verschickt über einen Kanal und schreibt das Ergebnis ins Versandprotokoll. */
|
||||
export async function ueberKanalSenden(
|
||||
kanal: KanalEintrag,
|
||||
nachricht: Nachricht,
|
||||
alertId: string | null = null,
|
||||
): Promise<void> {
|
||||
const empfaenger =
|
||||
kanal.typ === "EMAIL"
|
||||
? (kanal.konfiguration as EmailKonfiguration).empfaenger.join(", ")
|
||||
: (kanal.konfiguration as WebhookKonfiguration).url;
|
||||
|
||||
try {
|
||||
if (kanal.typ === "EMAIL") {
|
||||
await perEmailSenden(kanal.konfiguration as EmailKonfiguration, nachricht);
|
||||
} else {
|
||||
await perWebhookSenden(kanal.konfiguration as WebhookKonfiguration, nachricht);
|
||||
}
|
||||
|
||||
await prisma.benachrichtigung.create({
|
||||
data: {
|
||||
alertId,
|
||||
kanal: kanal.typ,
|
||||
empfaenger,
|
||||
betreff: nachricht.betreff,
|
||||
status: "GESENDET",
|
||||
versendetAm: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
await prisma.benachrichtigung.create({
|
||||
data: {
|
||||
alertId,
|
||||
kanal: kanal.typ,
|
||||
empfaenger,
|
||||
betreff: nachricht.betreff,
|
||||
status: "FEHLGESCHLAGEN",
|
||||
fehler: fehler instanceof Error ? fehler.message : String(fehler),
|
||||
},
|
||||
});
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { AlertSchwere } from "@/generated/prisma/enums";
|
||||
import { formatDatum } from "@/lib/format";
|
||||
import type { Nachricht } from "./kanaele";
|
||||
|
||||
/**
|
||||
* Textbausteine für Benachrichtigungen. Bewusst schlichtes HTML mit Inline-Stilen –
|
||||
* E-Mail-Programme ignorieren Stylesheets.
|
||||
*/
|
||||
|
||||
export interface AlertNachrichtDaten {
|
||||
id: string;
|
||||
regel: string;
|
||||
schwere: AlertSchwere;
|
||||
titel: string;
|
||||
beschreibung: string;
|
||||
hostId: string | null;
|
||||
}
|
||||
|
||||
const FARBE: Record<AlertSchwere, string> = {
|
||||
GEFAHR: "#C82333",
|
||||
WARNUNG: "#FFC107",
|
||||
INFO: "#138496",
|
||||
};
|
||||
|
||||
const SCHWERE_TEXT: Record<AlertSchwere, string> = {
|
||||
GEFAHR: "Gefahr",
|
||||
WARNUNG: "Warnung",
|
||||
INFO: "Hinweis",
|
||||
};
|
||||
|
||||
function rahmen(inhalt: string, appUrl: string): string {
|
||||
return `<div style="font-family:'Source Sans 3',Arial,sans-serif;font-size:15px;color:#1F2D3D;max-width:640px">
|
||||
<div style="border-bottom:3px solid #1E2A44;padding-bottom:8px;margin-bottom:16px">
|
||||
<span style="font-size:20px;color:#1E2A44;font-weight:600">ProxLedger</span>
|
||||
<span style="color:#6C757D"> · Proxmox Subscription Dashboard</span>
|
||||
</div>
|
||||
${inhalt}
|
||||
<p style="color:#6C757D;font-size:13px;margin-top:24px">
|
||||
<a href="${appUrl}" style="color:#007BFF">Dashboard öffnen</a>
|
||||
</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Eine einzelne neue Warnung. */
|
||||
export function alertNachricht(alert: AlertNachrichtDaten, appUrl: string): Nachricht {
|
||||
const betreff = `[ProxLedger] ${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}`;
|
||||
const text = `${SCHWERE_TEXT[alert.schwere]}: ${alert.titel}\n${alert.beschreibung}\n\nRegel: ${alert.regel}\n${appUrl}/warnungen`;
|
||||
|
||||
const html = rahmen(
|
||||
`<p style="margin:0 0 4px"><span style="display:inline-block;padding:2px 8px;border-radius:4px;background:${FARBE[alert.schwere]};color:${
|
||||
alert.schwere === "WARNUNG" ? "#1F2D3D" : "#FFFFFF"
|
||||
};font-size:13px">${SCHWERE_TEXT[alert.schwere]}</span></p>
|
||||
<h2 style="margin:8px 0;font-size:18px">${alert.titel}</h2>
|
||||
<p style="margin:0 0 8px">${alert.beschreibung}</p>
|
||||
<p style="color:#6C757D;font-size:13px;margin:0">Regel: ${alert.regel}</p>`,
|
||||
appUrl,
|
||||
);
|
||||
|
||||
return {
|
||||
betreff,
|
||||
text,
|
||||
html,
|
||||
daten: {
|
||||
art: "warnung",
|
||||
alertId: alert.id,
|
||||
regel: alert.regel,
|
||||
schwere: alert.schwere,
|
||||
titel: alert.titel,
|
||||
beschreibung: alert.beschreibung,
|
||||
hostId: alert.hostId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ZusammenfassungDaten {
|
||||
zeitraum: "taeglich" | "woechentlich";
|
||||
offeneAlerts: AlertNachrichtDaten[];
|
||||
neueAlerts: AlertNachrichtDaten[];
|
||||
baldAblaufend: { hostname: string; kunde: string; datum: Date; tage: number }[];
|
||||
offeneRechnungspunkte: number;
|
||||
hostsOhneMeldung: number;
|
||||
}
|
||||
|
||||
/** Tägliche oder wöchentliche Zusammenfassung. */
|
||||
export function zusammenfassungNachricht(daten: ZusammenfassungDaten, appUrl: string): Nachricht {
|
||||
const titel = daten.zeitraum === "taeglich" ? "Tagesübersicht" : "Wochenübersicht";
|
||||
const betreff = `[ProxLedger] ${titel}: ${daten.offeneAlerts.length} offene Warnungen`;
|
||||
|
||||
const zeilen = [
|
||||
`${titel} vom ${formatDatum(new Date())}`,
|
||||
"",
|
||||
`Offene Warnungen: ${daten.offeneAlerts.length} (davon neu: ${daten.neueAlerts.length})`,
|
||||
`Offene Rechnungspunkte: ${daten.offeneRechnungspunkte}`,
|
||||
`Hosts ohne Meldung: ${daten.hostsOhneMeldung}`,
|
||||
"",
|
||||
"Bald ablaufende Subscriptions:",
|
||||
...(daten.baldAblaufend.length > 0
|
||||
? daten.baldAblaufend.map(
|
||||
(e) => ` · ${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
|
||||
)
|
||||
: [" keine in den nächsten 30 Tagen"]),
|
||||
"",
|
||||
"Offene Warnungen:",
|
||||
...(daten.offeneAlerts.length > 0
|
||||
? daten.offeneAlerts.map((a) => ` · [${SCHWERE_TEXT[a.schwere]}] ${a.titel} – ${a.beschreibung}`)
|
||||
: [" keine"]),
|
||||
"",
|
||||
`${appUrl}/warnungen`,
|
||||
];
|
||||
|
||||
const liste = (eintraege: string[]) =>
|
||||
eintraege.length > 0
|
||||
? `<ul style="margin:4px 0 16px;padding-left:20px">${eintraege.map((e) => `<li>${e}</li>`).join("")}</ul>`
|
||||
: `<p style="color:#6C757D;margin:4px 0 16px">keine</p>`;
|
||||
|
||||
const html = rahmen(
|
||||
`<h2 style="margin:0 0 12px;font-size:18px">${titel}</h2>
|
||||
<table style="border-collapse:collapse;margin-bottom:16px">
|
||||
<tr><td style="padding:2px 16px 2px 0">Offene Warnungen</td><td><strong>${daten.offeneAlerts.length}</strong> (neu: ${daten.neueAlerts.length})</td></tr>
|
||||
<tr><td style="padding:2px 16px 2px 0">Offene Rechnungspunkte</td><td><strong>${daten.offeneRechnungspunkte}</strong></td></tr>
|
||||
<tr><td style="padding:2px 16px 2px 0">Hosts ohne Meldung</td><td><strong>${daten.hostsOhneMeldung}</strong></td></tr>
|
||||
</table>
|
||||
<h3 style="font-size:15px;margin:0">Bald ablaufende Subscriptions</h3>
|
||||
${liste(
|
||||
daten.baldAblaufend.map(
|
||||
(e) => `${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`,
|
||||
),
|
||||
)}
|
||||
<h3 style="font-size:15px;margin:0">Offene Warnungen</h3>
|
||||
${liste(
|
||||
daten.offeneAlerts.map(
|
||||
(a) =>
|
||||
`<span style="color:${FARBE[a.schwere]}">${SCHWERE_TEXT[a.schwere]}</span> – ${a.titel}: ${a.beschreibung}`,
|
||||
),
|
||||
)}`,
|
||||
appUrl,
|
||||
);
|
||||
|
||||
return {
|
||||
betreff,
|
||||
text: zeilen.join("\n"),
|
||||
html,
|
||||
daten: {
|
||||
art: "zusammenfassung",
|
||||
zeitraum: daten.zeitraum,
|
||||
offeneAlerts: daten.offeneAlerts.length,
|
||||
neueAlerts: daten.neueAlerts.length,
|
||||
offeneRechnungspunkte: daten.offeneRechnungspunkte,
|
||||
hostsOhneMeldung: daten.hostsOhneMeldung,
|
||||
baldAblaufend: daten.baldAblaufend.map((e) => ({
|
||||
hostname: e.hostname,
|
||||
kunde: e.kunde,
|
||||
datum: e.datum.toISOString().slice(0, 10),
|
||||
tage: e.tage,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Testnachricht für den Knopf „Test senden“. */
|
||||
export function testNachricht(appUrl: string): Nachricht {
|
||||
return {
|
||||
betreff: "[ProxLedger] Testnachricht",
|
||||
text: `Diese Testnachricht bestätigt, dass der Versand funktioniert.\n${appUrl}`,
|
||||
html: rahmen(
|
||||
`<h2 style="margin:0 0 8px;font-size:18px">Testnachricht</h2>
|
||||
<p style="margin:0">Der Versand funktioniert.</p>`,
|
||||
appUrl,
|
||||
),
|
||||
daten: { art: "test" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { kennzahlen } from "@/server/queries/hosts";
|
||||
import { aktiveKanaele, ueberKanalSenden, type Nachricht } from "./kanaele";
|
||||
import { alertNachricht, zusammenfassungNachricht, type AlertNachrichtDaten } from "./nachrichten";
|
||||
|
||||
/**
|
||||
* Versandlogik: Wer bekommt wann was?
|
||||
* Grundregel aus dem Auftrag: keine doppelten Benachrichtigungen für denselben offenen Alert.
|
||||
* Das stellt die Prüfung gegen das Versandprotokoll sicher – auch wenn eine Auswertung mehrfach läuft.
|
||||
*/
|
||||
|
||||
function appUrl(): string {
|
||||
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Wurde zu diesem Alert schon erfolgreich benachrichtigt? */
|
||||
async function bereitsGemeldet(alertId: string): Promise<boolean> {
|
||||
const vorhanden = await prisma.benachrichtigung.findFirst({
|
||||
where: { alertId, status: "GESENDET" },
|
||||
});
|
||||
return vorhanden !== null;
|
||||
}
|
||||
|
||||
export interface VersandErgebnis {
|
||||
gesendet: number;
|
||||
uebersprungen: number;
|
||||
fehlgeschlagen: number;
|
||||
}
|
||||
|
||||
/** Verschickt Benachrichtigungen für neu geöffnete Warnungen. */
|
||||
export async function neueAlertsMelden(fingerprints: string[]): Promise<VersandErgebnis> {
|
||||
if (fingerprints.length === 0) return { gesendet: 0, uebersprungen: 0, fehlgeschlagen: 0 };
|
||||
|
||||
const kanaele = await aktiveKanaele();
|
||||
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: fingerprints.length, fehlgeschlagen: 0 };
|
||||
|
||||
const alerts = await prisma.alert.findMany({
|
||||
where: { fingerprint: { in: fingerprints }, erledigtAm: null },
|
||||
});
|
||||
|
||||
let gesendet = 0;
|
||||
let uebersprungen = 0;
|
||||
let fehlgeschlagen = 0;
|
||||
|
||||
for (const alert of alerts) {
|
||||
if (await bereitsGemeldet(alert.id)) {
|
||||
uebersprungen += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nachricht = alertNachricht(
|
||||
{
|
||||
id: alert.id,
|
||||
regel: alert.regel,
|
||||
schwere: alert.schwere,
|
||||
titel: alert.titel,
|
||||
beschreibung: alert.beschreibung,
|
||||
hostId: alert.hostId,
|
||||
},
|
||||
appUrl(),
|
||||
);
|
||||
|
||||
for (const kanal of kanaele) {
|
||||
try {
|
||||
await ueberKanalSenden(kanal, nachricht, alert.id);
|
||||
gesendet += 1;
|
||||
} catch (fehler) {
|
||||
fehlgeschlagen += 1;
|
||||
console.error(`Versand über ${kanal.name} fehlgeschlagen:`, fehler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { gesendet, uebersprungen, fehlgeschlagen };
|
||||
}
|
||||
|
||||
/** Stellt die Zusammenfassung zusammen und verschickt sie. */
|
||||
export async function zusammenfassungSenden(
|
||||
zeitraum: "taeglich" | "woechentlich",
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<VersandErgebnis> {
|
||||
const kanaele = await aktiveKanaele();
|
||||
if (kanaele.length === 0) return { gesendet: 0, uebersprungen: 1, fehlgeschlagen: 0 };
|
||||
|
||||
const seit = new Date(jetzt.getTime() - (zeitraum === "taeglich" ? 1 : 7) * 86_400_000);
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
|
||||
const [offene, neue, ablaufend, zahlen] = await Promise.all([
|
||||
prisma.alert.findMany({ where: { erledigtAm: null }, orderBy: { schwere: "asc" } }),
|
||||
prisma.alert.findMany({ where: { erledigtAm: null, geoeffnetAm: { gte: seit } } }),
|
||||
prisma.host.findMany({
|
||||
where: { subNextduedate: { gte: jetzt, lte: in30Tagen } },
|
||||
orderBy: { subNextduedate: "asc" },
|
||||
select: { hostname: true, subNextduedate: true, kunde: { select: { name: true } } },
|
||||
}),
|
||||
kennzahlen(jetzt),
|
||||
]);
|
||||
|
||||
const alsDaten = (alert: (typeof offene)[number]): AlertNachrichtDaten => ({
|
||||
id: alert.id,
|
||||
regel: alert.regel,
|
||||
schwere: alert.schwere,
|
||||
titel: alert.titel,
|
||||
beschreibung: alert.beschreibung,
|
||||
hostId: alert.hostId,
|
||||
});
|
||||
|
||||
const nachricht = zusammenfassungNachricht(
|
||||
{
|
||||
zeitraum,
|
||||
offeneAlerts: offene.map(alsDaten),
|
||||
neueAlerts: neue.map(alsDaten),
|
||||
baldAblaufend: ablaufend.map((host) => ({
|
||||
hostname: host.hostname,
|
||||
kunde: host.kunde?.name ?? "Nicht zugeordnet",
|
||||
datum: host.subNextduedate!,
|
||||
tage: Math.round((host.subNextduedate!.getTime() - jetzt.getTime()) / 86_400_000),
|
||||
})),
|
||||
offeneRechnungspunkte: zahlen.offeneRechnungspunkte,
|
||||
hostsOhneMeldung: zahlen.ohneMeldung,
|
||||
},
|
||||
appUrl(),
|
||||
);
|
||||
|
||||
let gesendet = 0;
|
||||
let fehlgeschlagen = 0;
|
||||
for (const kanal of kanaele) {
|
||||
try {
|
||||
await ueberKanalSenden(kanal, nachricht, null);
|
||||
gesendet += 1;
|
||||
} catch {
|
||||
fehlgeschlagen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { gesendet, uebersprungen: 0, fehlgeschlagen };
|
||||
}
|
||||
|
||||
/** Verschickt eine Nachricht über genau einen Kanal – für „Test senden“. */
|
||||
export async function ueberEinenKanalSenden(kanalId: string, nachricht: Nachricht): Promise<void> {
|
||||
const kanaele = await aktiveKanaele();
|
||||
const kanal =
|
||||
kanaele.find((eintrag) => eintrag.id === kanalId) ??
|
||||
(await (async () => {
|
||||
const eintrag = await prisma.benachrichtigungskanal.findUnique({ where: { id: kanalId } });
|
||||
if (!eintrag) return undefined;
|
||||
return {
|
||||
id: eintrag.id,
|
||||
typ: eintrag.typ,
|
||||
name: eintrag.name,
|
||||
aktiv: eintrag.aktiv,
|
||||
konfiguration: eintrag.konfiguration as never,
|
||||
};
|
||||
})());
|
||||
|
||||
if (!kanal) throw new Error("Kanal nicht gefunden.");
|
||||
await ueberKanalSenden(kanal, nachricht, null);
|
||||
}
|
||||
Reference in New Issue
Block a user