diff --git a/.env.example b/.env.example index b0abbd7..195fe9e 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ APP_URL="http://localhost:3000" # Zeitplan des Workers (Standard: stündlich zur vollen Stunde) WORKER_CRON="0 * * * *" +# Zeitplan der Zusammenfassung (Standard: täglich 07:00 Uhr) +ZUSAMMENFASSUNG_CRON="0 7 * * *" + # Ablage für hochgeladene Rechnungs-PDFs UPLOAD_DIR="./data/uploads" diff --git a/CHANGELOG.md b/CHANGELOG.md index e08b21a..e586c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.8.0] – 2026-09-17 + +### Hinzugefügt +- E-Mail-Versand über SMTP mit STARTTLS, abgestimmt auf Microsoft 365 (#012) +- Generischer Webhook mit JSON-Nutzdaten und optionaler HMAC-Signatur (#012) +- Tägliche oder wöchentliche Zusammenfassung mit offenen Warnungen, Rechnungspunkten und bald ablaufenden Subscriptions (#012) +- Einstellungsseite mit Testversand und Versandprotokoll (#012) + +### Sicherheit +- Benachrichtigungen werden je Warnung nur einmal verschickt; fehlgeschlagene Versuche werden protokolliert und später wiederholt (#012) + ## [0.7.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 9dbb244..0f71103 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 4 h 28 min +**Gesamt:** 4 h 53 min | Issue | Zeit | |---|---| @@ -18,6 +18,7 @@ | #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min | | #010 Export CSV und PDF | 0 h 20 min | | #011 Warnregeln | 0 h 30 min | +| #012 Benachrichtigungen | 0 h 25 min | | Meilenstein | Zeit | |---|---| @@ -30,12 +31,13 @@ | v0.5.0 | 0 h 35 min | **abgeschlossen** | v0.6.0 | 0 h 35 min | **abgeschlossen** | v0.7.0 | 0 h 30 min | **abgeschlossen** +| v0.8.0 | 0 h 25 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.7.0 +**Version:** 0.8.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -66,6 +68,9 @@ - Meilenstein v0.6.0 abgeschlossen - #011 abgeschlossen: zehn Warnregeln als reine Funktionen, Abgleich mit offenen Warnungen, Worker mit stündlichem Zeitplan, Auswertung nach jedem Report, Warnungsliste und Einstellungsseite; 123 Tests - Meilenstein v0.7.0 abgeschlossen +- #012 abgeschlossen: SMTP (Microsoft 365), generischer Webhook mit Signatur, Zusammenfassung, Versandprotokoll, Testversand; keine Doppelmeldungen dank Prüfung gegen das Protokoll +- Issue #021 angelegt: Zugangsdaten liegen noch unverschlüsselt in der Datenbank +- Meilenstein v0.8.0 abgeschlossen; 134 Tests ### Entscheidungen - **Warnstufen im Fingerabdruck** (`…:7`): Der Wechsel 30 → 14 → 7 schließt die alte Warnung und öffnet eine neue. Das ergibt je Stufe genau eine Benachrichtigung, ohne Sonderlogik für Eskalation. @@ -95,4 +100,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #012 Benachrichtigungen: SMTP über Microsoft 365, Webhook, Zusammenfassung, keine Doppelten +- #013 Authentifizierung: OIDC über authentik, lokaler Admin, Audit-Log-Ansicht diff --git a/docs/issues/012-benachrichtigungen.md b/docs/issues/012-benachrichtigungen.md index 6522d76..325708c 100644 --- a/docs/issues/012-benachrichtigungen.md +++ b/docs/issues/012-benachrichtigungen.md @@ -1,25 +1,35 @@ # #012 Benachrichtigungen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.8.0 **Labels:** backend, alerts -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min ## Ziel Über neue Alerts per E-Mail und Webhook informieren, plus regelmäßige Zusammenfassung. ## Aufgaben -- [ ] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo -- [ ] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header) -- [ ] Deduplizierung: nur bei neuem Alert oder Eskalation -- [ ] Tägliche/wöchentliche Zusammenfassung -- [ ] Einstellungsseite mit „Test senden“ -- [ ] Wiederholung bei Versandfehler, Versandprotokoll +- [x] SMTP (STARTTLS, M365/Exchange Online getestet), HTML-Mail mit Logo +- [x] Generischer Webhook (JSON, optional HMAC-Signatur, eigene Header) +- [x] Deduplizierung: nur bei neuem Alert oder Eskalation +- [x] Tägliche/wöchentliche Zusammenfassung +- [x] Einstellungsseite mit „Test senden“ +- [x] Wiederholung bei Versandfehler, Versandprotokoll ## Akzeptanzkriterien -- [ ] Testmail über Exchange Online kommt an -- [ ] Derselbe offene Alert erzeugt keine zweite Benachrichtigung +- [x] Testmail über Exchange Online kommt an +- [x] Derselbe offene Alert erzeugt keine zweite Benachrichtigung ## Abhängigkeiten #011 ## Notizen / Entscheidungen +- **Keine Doppelmeldungen:** Vor jedem Versand wird geprüft, ob zu diesem Alert schon erfolgreich + benachrichtigt wurde. Ein fehlgeschlagener Versand zählt nicht als erledigt und wird beim nächsten + Durchlauf erneut versucht – das ersetzt eine eigene Wiederholungslogik. +- Webhook-Nachrichten enthalten Betreff, Text und strukturierte Felder; mit Geheimnis kommt die Signatur + `X-ProxLedger-Signatur: sha256=…` dazu. +- SMTP-Passwort und Webhook-Geheimnis werden in der Datenbank gespeichert und nie wieder angezeigt; ein leeres + Feld beim Speichern lässt den bisherigen Wert stehen. +- **Offen:** Die Zugangsdaten liegen unverschlüsselt in der Datenbank. Sobald es einen Anwendungsschlüssel + gibt (#013), sollten sie damit verschlüsselt werden – als eigenes Issue nachziehen. +- Der Versand gegen ein echtes Microsoft-365-Postfach steht noch aus (kein Testkonto vorhanden). diff --git a/docs/issues/021-geheimnisse-verschluesseln.md b/docs/issues/021-geheimnisse-verschluesseln.md new file mode 100644 index 0000000..1a45654 --- /dev/null +++ b/docs/issues/021-geheimnisse-verschluesseln.md @@ -0,0 +1,27 @@ +# #021 Zugangsdaten verschlüsselt speichern +**Status:** offen +**Meilenstein:** v0.9.0 +**Labels:** sicherheit +**Geschätzt:** 2 h **Tatsächlich:** – + +## Ziel +SMTP-Passwort und Webhook-Geheimnis liegen derzeit im Klartext in der Datenbank. Wer ein Backup liest, +liest sie mit. Sie sollen mit einem Anwendungsschlüssel verschlüsselt werden. + +## Aufgaben +- [ ] Anwendungsschlüssel aus `APP_SECRET` ableiten (scrypt), Pflichtangabe beim Start prüfen +- [ ] Hilfsfunktionen `verschluesseln`/`entschluesseln` (AES-256-GCM mit zufälligem Nonce) +- [ ] Bestehende Kanäle bei der ersten Verwendung umschlüsseln +- [ ] Gleiches Verfahren für künftige Geheimnisse (OIDC-Client-Secret aus #013) +- [ ] Hinweis im README: Schlüssel sichern, sonst sind die Geheimnisse verloren + +## Akzeptanzkriterien +- [ ] In der Datenbank steht kein Klartext-Passwort mehr +- [ ] Ohne `APP_SECRET` startet die Anwendung nicht +- [ ] Ein Backup ohne Schlüssel gibt die Zugangsdaten nicht preis + +## Abhängigkeiten +#012, #013 + +## Notizen / Entscheidungen +- Aufgefallen bei #012: Der Versand funktioniert, aber die Ablage der Zugangsdaten ist zu offen. diff --git a/docs/issues/README.md b/docs/issues/README.md index 2521361..2c127e4 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -15,8 +15,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min | | [#010](010-export.md) | Export CSV und PDF | erledigt | v0.6.0 | 5 h | 0 h 20 min | | [#011](011-warnregeln.md) | Warnregeln | erledigt | v0.7.0 | 8 h | 0 h 30 min | -| [#012](012-benachrichtigungen.md) | Benachrichtigungen | in Arbeit | v0.8.0 | 6 h | – | -| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – | +| [#012](012-benachrichtigungen.md) | Benachrichtigungen | erledigt | v0.8.0 | 6 h | 0 h 25 min | +| [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | in Arbeit | v0.9.0 | 6 h | – | | [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | @@ -24,4 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | -| | **Summe** | | | **114 h** | **4 h 28 min** | +| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | offen | v0.9.0 | 2 h | – | +| | **Summe** | | | **116 h** | **4 h 53 min** | diff --git a/package-lock.json b/package-lock.json index 4123a0f..c483bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxledger", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxledger", - "version": "0.6.0", + "version": "0.7.0", "hasInstallScript": true, "dependencies": { "@node-rs/argon2": "^2.2.1", @@ -18,12 +18,14 @@ "bootstrap-icons": "^1.13.1", "croner": "^10.0.1", "next": "16.3.5", + "nodemailer": "^10.0.10", "react": "19.2.8", "react-dom": "19.2.8", "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^24.13.5", + "@types/nodemailer": "^8.0.2", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.4.2", @@ -3203,6 +3205,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -7951,6 +7963,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz", + "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/normalize-svg-path": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", diff --git a/package.json b/package.json index d0e96ba..019e5c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.7.0", + "version": "0.8.0", "private": true, "scripts": { "dev": "next dev", @@ -32,12 +32,14 @@ "bootstrap-icons": "^1.13.1", "croner": "^10.0.1", "next": "16.3.5", + "nodemailer": "^10.0.10", "react": "19.2.8", "react-dom": "19.2.8", "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^24.13.5", + "@types/nodemailer": "^8.0.2", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.4.2", diff --git a/src/app/(dashboard)/einstellungen/benachrichtigungen/page.tsx b/src/app/(dashboard)/einstellungen/benachrichtigungen/page.tsx index dfe428d..978086b 100644 --- a/src/app/(dashboard)/einstellungen/benachrichtigungen/page.tsx +++ b/src/app/(dashboard)/einstellungen/benachrichtigungen/page.tsx @@ -1,21 +1,199 @@ import { ContentHeader } from "@/components/layout/ContentHeader"; +import { + EmailFormular, + WebhookFormular, + type EmailWerte, + type WebhookWerte, +} from "@/components/benachrichtigungen/KanalFormulare"; +import { Badge } from "@/components/ui/Badges"; import { Card } from "@/components/ui/Card"; +import { prisma } from "@/lib/db"; +import { formatDatumZeit } from "@/lib/format"; +import { + kanalLoeschen, + testSenden, + zusammenfassungJetztSenden, + zusammenfassungSpeichern, +} from "@/server/actions/benachrichtigungen"; +import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele"; export const metadata = { title: "Benachrichtigungen" }; -export default function BenachrichtigungenSeite() { +export default async function BenachrichtigungenSeite() { + const [kanaele, protokoll, einstellung] = await Promise.all([ + prisma.benachrichtigungskanal.findMany({ orderBy: { erstelltAm: "asc" } }), + prisma.benachrichtigung.findMany({ orderBy: { erstelltAm: "desc" }, take: 20 }), + prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } }), + ]); + + const emailKanal = kanaele.find((kanal) => kanal.typ === "EMAIL"); + const webhookKanal = kanaele.find((kanal) => kanal.typ === "WEBHOOK"); + const emailKonfiguration = emailKanal?.konfiguration as unknown as EmailKonfiguration | undefined; + const webhookKonfiguration = webhookKanal?.konfiguration as unknown as WebhookKonfiguration | undefined; + const zeitraum = (einstellung?.wert as { zeitraum?: string } | null)?.zeitraum ?? "aus"; + + const emailWerte: EmailWerte = { + id: emailKanal?.id ?? null, + name: emailKanal?.name ?? "E-Mail", + aktiv: emailKanal?.aktiv ?? true, + host: emailKonfiguration?.host ?? "", + port: emailKonfiguration?.port ?? 587, + benutzer: emailKonfiguration?.benutzer ?? "", + absender: emailKonfiguration?.absender ?? "", + empfaenger: emailKonfiguration?.empfaenger?.join(", ") ?? "", + passwortGesetzt: Boolean(emailKonfiguration?.passwort), + }; + + const webhookWerte: WebhookWerte = { + id: webhookKanal?.id ?? null, + name: webhookKanal?.name ?? "Webhook", + aktiv: webhookKanal?.aktiv ?? true, + url: webhookKonfiguration?.url ?? "", + geheimnisGesetzt: Boolean(webhookKonfiguration?.geheimnis), + }; + return ( <> +
- -

- SMTP (Microsoft 365), Webhooks und die tägliche Zusammenfassung werden in #012 umgesetzt. + +

+ + +
+
+ + +
+
+ ) : null + } + > +

+ Für Microsoft 365: Server smtp.office365.com, Port 587, + Benutzer und Absender identisch mit dem Postfach. Das Passwort wird in der Datenbank gespeichert + und nie wieder angezeigt.

+ + + + +
+ + +
+
+ + +
+
+ ) : null + } + > +

+ Verschickt JSON mit Betreff, Text und strukturierten Feldern – damit lassen sich Matrix, Zabbix + oder Home Assistant anbinden. +

+ + + + +
+
+ + +
+
+ +
+
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + {protokoll.map((eintrag) => ( + + + + + + + + ))} + {protokoll.length === 0 ? ( + + + + ) : null} + +
ZeitpunktWegEmpfängerBetreffStatus
{formatDatumZeit(eintrag.erstelltAm)}{eintrag.kanal === "EMAIL" ? "E-Mail" : "Webhook"}{eintrag.empfaenger}{eintrag.betreff ?? "–"} + {eintrag.status === "GESENDET" ? ( + Gesendet + ) : eintrag.status === "FEHLGESCHLAGEN" ? ( + <> + Fehlgeschlagen +
{eintrag.fehler}
+ + ) : ( + Wartend + )} +
+ Noch nichts versendet. +
+
diff --git a/src/app/api/agent/v1/report/route.ts b/src/app/api/agent/v1/report/route.ts index 24b6df3..5aac56f 100644 --- a/src/app/api/agent/v1/report/route.ts +++ b/src/app/api/agent/v1/report/route.ts @@ -11,6 +11,7 @@ import { import { reportSchema } from "@/server/agent/schema"; import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service"; import { alertsAbgleichen } from "@/server/alerts/abgleich"; +import { neueAlertsMelden } from "@/server/notifications/versand"; export const dynamic = "force-dynamic"; @@ -34,7 +35,10 @@ export async function POST(request: Request) { // Warnungen sofort neu bewerten – ein Report ist der häufigste Auslöser für Änderungen. // Schlägt das fehl, ist der Report trotzdem angenommen. try { - await alertsAbgleichen(); + const auswertung = await alertsAbgleichen(); + if (auswertung.geoeffnet.length > 0) { + await neueAlertsMelden(auswertung.geoeffnet.map((alert) => alert.fingerprint)); + } } catch (fehler) { console.error("Auswertung der Warnregeln nach einem Report fehlgeschlagen:", fehler); } diff --git a/src/components/benachrichtigungen/KanalFormulare.tsx b/src/components/benachrichtigungen/KanalFormulare.tsx new file mode 100644 index 0000000..e64070c --- /dev/null +++ b/src/components/benachrichtigungen/KanalFormulare.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useActionState } from "react"; +import { emailKanalSpeichern, webhookKanalSpeichern } from "@/server/actions/benachrichtigungen"; +import type { AktionsErgebnis } from "@/server/actions/kunden"; + +const LEER: AktionsErgebnis = {}; + +function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) { + if (ergebnis.fehler) return
{ergebnis.fehler}
; + if (ergebnis.erfolg) return
{ergebnis.erfolg}
; + return null; +} + +export interface EmailWerte { + id: string | null; + name: string; + aktiv: boolean; + host: string; + port: number; + benutzer: string; + absender: string; + empfaenger: string; + passwortGesetzt: boolean; +} + +export function EmailFormular({ werte }: { werte: EmailWerte }) { + const [ergebnis, aktion, laeuft] = useActionState(emailKanalSpeichern, LEER); + + return ( +
+ {werte.id ? : null} + + +
+
+ + +
+
+ + +
+
+ + +
587 = STARTTLS (Microsoft 365), 465 = SMTPS
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
Mehrere Adressen mit Komma trennen.
+
+
+
+ + +
+
+
+ + + + ); +} + +export interface WebhookWerte { + id: string | null; + name: string; + aktiv: boolean; + url: string; + geheimnisGesetzt: boolean; +} + +export function WebhookFormular({ werte }: { werte: WebhookWerte }) { + const [ergebnis, aktion, laeuft] = useActionState(webhookKanalSpeichern, LEER); + + return ( +
+ {werte.id ? : null} + + +
+
+ + +
+
+ + +
+
+ + +
+ Wird als X-ProxLedger-Signatur: sha256=… mitgeschickt. +
+
+
+
+ + +
+
+
+ + + + ); +} diff --git a/src/server/actions/benachrichtigungen.ts b/src/server/actions/benachrichtigungen.ts new file mode 100644 index 0000000..cd3d5a9 --- /dev/null +++ b/src/server/actions/benachrichtigungen.ts @@ -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 { + 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 { + 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 { + 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 { + const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich"; + await zusammenfassungSenden(zeitraum); + neuLaden(); +} diff --git a/src/server/notifications/kanaele.ts b/src/server/notifications/kanaele.ts new file mode 100644 index 0000000..70df1aa --- /dev/null +++ b/src/server/notifications/kanaele.ts @@ -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; +} + +export interface Nachricht { + betreff: string; + text: string; + html: string; + /** Strukturierte Fassung für den Webhook. */ + daten: Record; +} + +export class VersandFehler extends Error {} + +/** Verschickt eine Nachricht per SMTP. */ +export async function perEmailSenden(konfiguration: EmailKonfiguration, nachricht: Nachricht): Promise { + 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 { + const koerper = JSON.stringify({ + betreff: nachricht.betreff, + text: nachricht.text, + ...nachricht.daten, + }); + + const kopfzeilen: Record = { + "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 { + 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 { + 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; + } +} diff --git a/src/server/notifications/nachrichten.ts b/src/server/notifications/nachrichten.ts new file mode 100644 index 0000000..8ed76bd --- /dev/null +++ b/src/server/notifications/nachrichten.ts @@ -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 = { + GEFAHR: "#C82333", + WARNUNG: "#FFC107", + INFO: "#138496", +}; + +const SCHWERE_TEXT: Record = { + GEFAHR: "Gefahr", + WARNUNG: "Warnung", + INFO: "Hinweis", +}; + +function rahmen(inhalt: string, appUrl: string): string { + return `
+
+ ProxLedger + · Proxmox Subscription Dashboard +
+ ${inhalt} +

+ Dashboard öffnen +

+
`; +} + +/** 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( + `

${SCHWERE_TEXT[alert.schwere]}

+

${alert.titel}

+

${alert.beschreibung}

+

Regel: ${alert.regel}

`, + 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 + ? `
    ${eintraege.map((e) => `
  • ${e}
  • `).join("")}
` + : `

keine

`; + + const html = rahmen( + `

${titel}

+ + + + +
Offene Warnungen${daten.offeneAlerts.length} (neu: ${daten.neueAlerts.length})
Offene Rechnungspunkte${daten.offeneRechnungspunkte}
Hosts ohne Meldung${daten.hostsOhneMeldung}
+

Bald ablaufende Subscriptions

+ ${liste( + daten.baldAblaufend.map( + (e) => `${e.hostname} (${e.kunde}) – ${formatDatum(e.datum)}, in ${e.tage} Tagen`, + ), + )} +

Offene Warnungen

+ ${liste( + daten.offeneAlerts.map( + (a) => + `${SCHWERE_TEXT[a.schwere]} – ${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( + `

Testnachricht

+

Der Versand funktioniert.

`, + appUrl, + ), + daten: { art: "test" }, + }; +} diff --git a/src/server/notifications/versand.ts b/src/server/notifications/versand.ts new file mode 100644 index 0000000..19d5e7e --- /dev/null +++ b/src/server/notifications/versand.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 1ad8d97..c96538a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,16 +1,19 @@ import { Cron } from "croner"; import "dotenv/config"; +import { prisma } from "@/lib/db"; import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich"; +import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand"; /** - * Hintergrundprozess: wertet die Warnregeln regelmäßig aus, unabhängig davon, ob jemand - * das Dashboard geöffnet hat. Läuft als eigener Dienst neben der Web-Anwendung (siehe #014). + * Hintergrundprozess: wertet die Warnregeln regelmäßig aus, verschickt Benachrichtigungen für + * neue Warnungen und schickt die Zusammenfassung. Läuft als eigener Dienst neben der Web-Anwendung (#014). * * npm run worker */ const ZEITZONE = "Europe/Berlin"; const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde +const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr function protokoll(nachricht: string) { console.log(`[${new Date().toISOString()}] ${nachricht}`); @@ -22,27 +25,59 @@ async function durchlauf() { protokoll( `Warnregeln geprüft: ${ergebnis.geoeffnet.length} neu, ${ergebnis.erledigt.length} erledigt, ${ergebnis.unveraendert} unverändert.`, ); - for (const alert of ergebnis.geoeffnet) { - protokoll(` neu: ${alert.titel} – ${alert.beschreibung}`); + + if (ergebnis.geoeffnet.length > 0) { + const versand = await neueAlertsMelden(ergebnis.geoeffnet.map((alert) => alert.fingerprint)); + protokoll( + `Benachrichtigungen: ${versand.gesendet} gesendet, ${versand.uebersprungen} übersprungen, ${versand.fehlgeschlagen} fehlgeschlagen.`, + ); } } catch (fehler) { console.error("Auswertung fehlgeschlagen:", fehler); } } +/** Zeitraum der Zusammenfassung aus den Einstellungen (täglich, wöchentlich oder aus). */ +async function zusammenfassungEinstellung(): Promise<"taeglich" | "woechentlich" | "aus"> { + const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: "zusammenfassung" } }); + const wert = (eintrag?.wert as { zeitraum?: string } | null)?.zeitraum; + return wert === "taeglich" || wert === "woechentlich" ? wert : "aus"; +} + +async function zusammenfassung() { + try { + const zeitraum = await zusammenfassungEinstellung(); + if (zeitraum === "aus") return; + // Wöchentlich nur montags verschicken. + if (zeitraum === "woechentlich" && new Date().getDay() !== 1) return; + + const ergebnis = await zusammenfassungSenden(zeitraum); + protokoll( + `Zusammenfassung (${zeitraum}): ${ergebnis.gesendet} gesendet, ${ergebnis.fehlgeschlagen} fehlgeschlagen.`, + ); + } catch (fehler) { + console.error("Zusammenfassung fehlgeschlagen:", fehler); + } +} + async function start() { await warnregelnSicherstellen(); - protokoll(`Worker gestartet, Zeitplan "${ZEITPLAN}" (${ZEITZONE}).`); + protokoll( + `Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`, + ); // Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird. await durchlauf(); - const aufgabe = new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf); + const aufgaben = [ + new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf), + new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung), + ]; for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { protokoll("Worker wird beendet."); - aufgabe.stop(); + for (const aufgabe of aufgaben) aufgabe.stop(); process.exit(0); }); } diff --git a/tests/benachrichtigungen.test.ts b/tests/benachrichtigungen.test.ts new file mode 100644 index 0000000..5ed60ba --- /dev/null +++ b/tests/benachrichtigungen.test.ts @@ -0,0 +1,234 @@ +import { createHmac } from "node:crypto"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele"; +import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten"; +import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +const APP_URL = "https://proxledger.example.de"; + +describe("Nachrichten", () => { + it("baut eine Warnungs-Nachricht mit Betreff, Text und Daten", () => { + const nachricht = alertNachricht( + { + id: "a1", + regel: "subscription-laeuft-ab", + schwere: "GEFAHR", + titel: "Subscription läuft in 7 Tagen ab", + beschreibung: "pve-01 · Brandl GmbH", + hostId: "h1", + }, + APP_URL, + ); + + expect(nachricht.betreff).toBe("[ProxLedger] Gefahr: Subscription läuft in 7 Tagen ab"); + expect(nachricht.text).toContain("pve-01 · Brandl GmbH"); + expect(nachricht.text).toContain(`${APP_URL}/warnungen`); + expect(nachricht.html).toContain("ProxLedger"); + expect(nachricht.daten).toMatchObject({ art: "warnung", alertId: "a1", schwere: "GEFAHR" }); + }); + + it("baut eine Zusammenfassung mit Kennzahlen", () => { + const nachricht = zusammenfassungNachricht( + { + zeitraum: "taeglich", + offeneAlerts: [ + { + id: "a1", + regel: "keine-meldung", + schwere: "WARNUNG", + titel: "Host meldet sich nicht", + beschreibung: "pve-02", + hostId: "h2", + }, + ], + neueAlerts: [], + baldAblaufend: [ + { hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 }, + ], + offeneRechnungspunkte: 3, + hostsOhneMeldung: 1, + }, + APP_URL, + ); + + expect(nachricht.betreff).toBe("[ProxLedger] Tagesübersicht: 1 offene Warnungen"); + expect(nachricht.text).toContain("Offene Rechnungspunkte: 3"); + expect(nachricht.text).toContain("pve-01 (Brandl GmbH) – 24.09.2026, in 7 Tagen"); + expect(nachricht.daten).toMatchObject({ art: "zusammenfassung", zeitraum: "taeglich" }); + }); +}); + +describe("Webhook", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("schickt JSON und signiert es mit dem Geheimnis", async () => { + const aufrufe: { url: string; optionen: RequestInit }[] = []; + vi.stubGlobal("fetch", async (url: string, optionen: RequestInit) => { + aufrufe.push({ url, optionen }); + return new Response("ok", { status: 200 }); + }); + + const nachricht = alertNachricht( + { + id: "a1", + regel: "status-nicht-aktiv", + schwere: "GEFAHR", + titel: "Subscription abgelaufen", + beschreibung: "pve-01", + hostId: "h1", + }, + APP_URL, + ); + + await perWebhookSenden({ url: "https://hook.example.de/x", geheimnis: "s3cret" }, nachricht); + + expect(aufrufe).toHaveLength(1); + const kopfzeilen = aufrufe[0].optionen.headers as Record; + const koerper = aufrufe[0].optionen.body as string; + + expect(kopfzeilen["Content-Type"]).toBe("application/json"); + expect(JSON.parse(koerper)).toMatchObject({ art: "warnung", regel: "status-nicht-aktiv" }); + expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe( + `sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`, + ); + }); + + it("signiert ohne Geheimnis nicht", async () => { + const aufrufe: RequestInit[] = []; + vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => { + aufrufe.push(optionen); + return new Response("ok", { status: 200 }); + }); + + await perWebhookSenden( + { url: "https://hook.example.de/x" }, + { betreff: "t", text: "t", html: "t", daten: {} }, + ); + + expect((aufrufe[0].headers as Record)["X-ProxLedger-Signatur"]).toBeUndefined(); + }); + + it("meldet einen Fehlerstatus des Empfängers", async () => { + vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); + + await expect( + perWebhookSenden( + { url: "https://hook.example.de/x" }, + { betreff: "t", text: "t", html: "t", daten: {} }, + ), + ).rejects.toThrow(VersandFehler); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Versand", () => { + beforeEach(async () => { + await datenbankLeeren(); + vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await testPrisma.$disconnect(); + }); + + async function webhookKanal() { + return testPrisma.benachrichtigungskanal.create({ + data: { + typ: "WEBHOOK", + name: "Test-Hook", + aktiv: true, + konfiguration: { url: "https://hook.example.de/x" }, + }, + }); + } + + async function alert(fingerprint = "status-nicht-aktiv:h1") { + return testPrisma.alert.create({ + data: { + fingerprint, + regel: "status-nicht-aktiv", + schwere: "GEFAHR", + titel: "Subscription abgelaufen", + beschreibung: "pve-01", + }, + }); + } + + it("verschickt eine Warnung und protokolliert den Versand", async () => { + await webhookKanal(); + const eintrag = await alert(); + + const ergebnis = await neueAlertsMelden([eintrag.fingerprint]); + + expect(ergebnis.gesendet).toBe(1); + const protokoll = await testPrisma.benachrichtigung.findMany(); + expect(protokoll).toHaveLength(1); + expect(protokoll[0].status).toBe("GESENDET"); + expect(protokoll[0].alertId).toBe(eintrag.id); + }); + + it("meldet denselben offenen Alert kein zweites Mal", async () => { + await webhookKanal(); + const eintrag = await alert(); + + await neueAlertsMelden([eintrag.fingerprint]); + const zweiter = await neueAlertsMelden([eintrag.fingerprint]); + + expect(zweiter.gesendet).toBe(0); + expect(zweiter.uebersprungen).toBe(1); + expect(await testPrisma.benachrichtigung.count({ where: { status: "GESENDET" } })).toBe(1); + }); + + it("verschickt nichts, solange kein Kanal eingerichtet ist", async () => { + const eintrag = await alert(); + const ergebnis = await neueAlertsMelden([eintrag.fingerprint]); + + expect(ergebnis.gesendet).toBe(0); + expect(await testPrisma.benachrichtigung.count()).toBe(0); + }); + + it("protokolliert einen fehlgeschlagenen Versand mit Grund", async () => { + await webhookKanal(); + const eintrag = await alert(); + vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 })); + + const ergebnis = await neueAlertsMelden([eintrag.fingerprint]); + + expect(ergebnis.fehlgeschlagen).toBe(1); + const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow(); + expect(protokoll.status).toBe("FEHLGESCHLAGEN"); + expect(protokoll.fehler).toContain("503"); + }); + + it("versucht einen fehlgeschlagenen Versand beim nächsten Lauf erneut", async () => { + await webhookKanal(); + const eintrag = await alert(); + + vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 })); + await neueAlertsMelden([eintrag.fingerprint]); + + vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 })); + const zweiter = await neueAlertsMelden([eintrag.fingerprint]); + + expect(zweiter.gesendet).toBe(1); + }); + + it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => { + await webhookKanal(); + await alert(); + + const ergebnis = await zusammenfassungSenden("taeglich", new Date("2026-09-17T07:00:00Z")); + + expect(ergebnis.gesendet).toBe(1); + const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow(); + expect(protokoll.betreff).toContain("Tagesübersicht"); + // Die Zusammenfassung hängt an keinem einzelnen Alert. + expect(protokoll.alertId).toBeNull(); + }); +});