From 3bfed6d9f6747603603a5efd490688da2826533c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 10:22:50 +0200 Subject: [PATCH] feat(notify): Benachrichtigungen ueber ntfy, Webhook und E-Mail Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen. Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei jedem stuendlichen Durchlauf. Zwei Feinheiten: - Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede bereits vorhandene Version eine "Neuigkeit". - Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst kaeme beim ersten Ziel die ganze Vergangenheit hinterher. Zustellwege: - ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das Sinnbild liefert ohnehin der Tags-Header. - Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann. - E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist. Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere. Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf den Abgleich nicht scheitern lassen. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 25 +- package.json | 4 +- .../migration.sql | 39 ++++ prisma/schema.prisma | 55 +++++ src/lib/notify/channels.ts | 213 ++++++++++++++++++ src/lib/notify/index.ts | 159 +++++++++++++ src/lib/notify/pending.test.ts | 126 +++++++++++ src/lib/notify/types.ts | 66 ++++++ src/lib/sync/index.ts | 4 + src/lib/validation.ts | 34 +++ 10 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260726140000_notifications/migration.sql create mode 100644 src/lib/notify/channels.ts create mode 100644 src/lib/notify/index.ts create mode 100644 src/lib/notify/pending.test.ts create mode 100644 src/lib/notify/types.ts diff --git a/package-lock.json b/package-lock.json index 3304b92..b63ca54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nebula", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "0.5.0", + "version": "0.6.0", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -17,6 +17,7 @@ "@simplewebauthn/server": "^13.3.2", "lucide-react": "^0.511.0", "next": "^15.3.3", + "nodemailer": "^9.0.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -28,6 +29,7 @@ "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", "prisma": "^6.8.2", @@ -2319,6 +2321,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4390,6 +4402,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", diff --git a/package.json b/package.json index 67b9d32..57379d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "0.6.0", + "version": "0.7.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { @@ -42,6 +42,7 @@ "@simplewebauthn/server": "^13.3.2", "lucide-react": "^0.511.0", "next": "^15.3.3", + "nodemailer": "^9.0.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -53,6 +54,7 @@ "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", "prisma": "^6.8.2", diff --git a/prisma/migrations/20260726140000_notifications/migration.sql b/prisma/migrations/20260726140000_notifications/migration.sql new file mode 100644 index 0000000..767e3a0 --- /dev/null +++ b/prisma/migrations/20260726140000_notifications/migration.sql @@ -0,0 +1,39 @@ +-- CreateEnum +CREATE TYPE "NotificationChannel" AS ENUM ('WEBHOOK', 'NTFY', 'EMAIL'); + +-- CreateEnum +CREATE TYPE "NotificationEvent" AS ENUM ('SYNC_FAILED', 'CI_FAILED', 'NEW_RELEASE'); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "notifiedCiState" TEXT, +ADD COLUMN "notifiedRelease" TEXT, +ADD COLUMN "notifiedSyncError" TEXT; + +-- CreateTable +CREATE TABLE "NotificationTarget" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "channel" "NotificationChannel" NOT NULL, + "target" TEXT NOT NULL, + "secretCipher" TEXT, + "secretHint" TEXT, + "events" "NotificationEvent"[] DEFAULT ARRAY[]::"NotificationEvent"[], + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastSentAt" TIMESTAMP(3), + "lastOk" BOOLEAN, + "lastMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationTarget_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NotificationTarget_userId_idx" ON "NotificationTarget"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationTarget_userId_name_key" ON "NotificationTarget"("userId", "name"); + +-- AddForeignKey +ALTER TABLE "NotificationTarget" ADD CONSTRAINT "NotificationTarget_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 923d55f..d27bfea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,23 @@ enum GitProvider { GITEA } +/// Zustellwege fuer Benachrichtigungen. +enum NotificationChannel { + WEBHOOK + NTFY + EMAIL +} + +/// Anlaesse, ueber die benachrichtigt werden kann. +enum NotificationEvent { + /// Ein Abgleich mit dem Git-System ist fehlgeschlagen. + SYNC_FAILED + /// Der letzte CI-Lauf auf dem Default-Branch ist rot. + CI_FAILED + /// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht. + NEW_RELEASE +} + /// Spalten des Aufgabenbretts innerhalb eines Projekts. enum TaskStatus { TODO @@ -78,6 +95,7 @@ model User { credentials Credential[] projects Project[] connections GitConnection[] + notificationTargets NotificationTarget[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -249,6 +267,13 @@ model Project { /// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt. progressFromTasks Boolean @default(false) + /// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer + /// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem + /// Abgleich dieselbe Nachricht. + notifiedSyncError String? + notifiedCiState String? + notifiedRelease String? + notes Note[] tasks Task[] snapshot RepoSnapshot? @@ -263,6 +288,36 @@ model Project { @@index([ownerId]) } +/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto. +model NotificationTarget { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + channel NotificationChannel + + /// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse. + target String + /// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer + /// ntfy oder ein Signaturschluessel fuer den Webhook. + secretCipher String? + secretHint String? + + /// Worueber dieses Ziel benachrichtigt werden will. + events NotificationEvent[] @default([]) + isActive Boolean @default(true) + + lastSentAt DateTime? + lastOk Boolean? + lastMessage String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, name]) + @@index([userId]) +} + /// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts. model Task { id String @id @default(cuid()) diff --git a/src/lib/notify/channels.ts b/src/lib/notify/channels.ts new file mode 100644 index 0000000..cf3acd8 --- /dev/null +++ b/src/lib/notify/channels.ts @@ -0,0 +1,213 @@ +import { createHmac } from "node:crypto"; +import nodemailer from "nodemailer"; +import type { NotificationTarget } from "@prisma/client"; +import { tryDecryptSecret } from "@/lib/crypto"; +import { APP_NAME, APP_URL } from "@/lib/config"; +import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf"; +import type { DeliveryResult, NotificationMessage } from "./types"; + +/** + * Zustellung einer Nachricht ueber den gewaehlten Weg. + * + * Zwei Dinge gelten fuer alle Wege: + * - Die Zieladresse ist eine Benutzereingabe und wird serverseitig + * abgerufen. Sie laeuft deshalb durch denselben SSRF-Schutz wie die + * Git-Verbindungen. Ein ntfy im eigenen Netz bleibt damit erlaubt, + * Loopback und Metadaten-Endpunkte nicht. + * - Fehler werden zurueckgegeben, nicht geworfen. Eine kaputte + * Benachrichtigung darf einen Abgleich nicht scheitern lassen. + */ + +const TIMEOUT_MS = Number(process.env.NOTIFY_TIMEOUT_MS ?? 10_000); + +async function postJson( + url: string, + body: unknown, + headers: Record, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + signal: controller.signal, + // Ein Redirect koennte auf ein anderes Ziel fuehren, als geprueft + // wurde – deshalb gar nicht erst folgen. + redirect: "manual", + }); + if (!response.ok && response.status !== 0) { + return { ok: false, message: `Antwort ${response.status} von ${new URL(url).host}` }; + } + return { ok: true, message: `Zugestellt (${response.status})` }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` }; + } + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } +} + +async function sendWebhook( + target: NotificationTarget, + message: NotificationMessage, +): Promise { + const payload = { + source: APP_NAME, + event: message.event, + title: message.title, + body: message.body, + url: message.url, + project: message.project, + at: message.at, + }; + const raw = JSON.stringify(payload); + + const headers: Record = { "User-Agent": `${APP_NAME}-Notify` }; + + // Mit Geheimnis wird der Rumpf signiert, damit der Empfaenger pruefen + // kann, dass die Nachricht wirklich von hier kommt. + const secret = tryDecryptSecret(target.secretCipher); + if (secret) { + headers["X-Nebula-Signature"] = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`; + } + + return postJson(target.target, raw, headers); +} + +async function sendNtfy( + target: NotificationTarget, + message: NotificationMessage, +): Promise { + const headers: Record = { + Title: encodeHeader(message.title), + Priority: message.priority === "high" ? "high" : "default", + Tags: message.event === "TEST" ? "bell" : "warning", + }; + if (message.url) headers.Click = message.url; + + const secret = tryDecryptSecret(target.secretCipher); + if (secret) headers.Authorization = `Bearer ${secret}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch(target.target, { + method: "POST", + // ntfy nimmt den Rumpf als reinen Text. + headers: { "Content-Type": "text/plain; charset=utf-8", ...headers }, + body: message.body, + signal: controller.signal, + redirect: "manual", + }); + if (!response.ok) { + return { ok: false, message: `Antwort ${response.status} von ntfy` }; + } + return { ok: true, message: "An ntfy zugestellt" }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` }; + } + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } +} + +/** + * Kopfzeilen duerfen nur Latin-1 enthalten. Emojis werden deshalb entfernt, + * nicht durch Fragezeichen ersetzt – aus "🔴 Projekt: CI ist rot" wuerde + * sonst "?? Projekt: CI ist rot". Das Sinnbild traegt ohnehin der + * Tags-Header, den ntfy selbst in ein Symbol umsetzt. + */ +function encodeHeader(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[^\x20-\xFF]/g, "").replace(/\s+/g, " ").trim(); +} + +export interface SmtpConfig { + host: string; + port: number; + secure: boolean; + user?: string; + password?: string; + from: string; +} + +/** Liest die SMTP-Einstellungen aus der Umgebung, falls vorhanden. */ +export function smtpConfig(): SmtpConfig | null { + const host = process.env.SMTP_HOST?.trim(); + if (!host) return null; + + const port = Number(process.env.SMTP_PORT ?? 587); + return { + host, + port, + // Port 465 spricht von Anfang an TLS, 587 steigt per STARTTLS um. + secure: process.env.SMTP_SECURE + ? /^(1|true|yes|on)$/i.test(process.env.SMTP_SECURE) + : port === 465, + user: process.env.SMTP_USER?.trim() || undefined, + password: process.env.SMTP_PASSWORD || undefined, + from: process.env.SMTP_FROM?.trim() || `${APP_NAME} `, + }; +} + +export const emailAvailable = () => smtpConfig() !== null; + +async function sendEmail( + target: NotificationTarget, + message: NotificationMessage, +): Promise { + const config = smtpConfig(); + if (!config) { + return { ok: false, message: "Kein SMTP hinterlegt – SMTP_HOST in der .env setzen." }; + } + + try { + const transport = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.user ? { user: config.user, pass: config.password } : undefined, + connectionTimeout: TIMEOUT_MS, + greetingTimeout: TIMEOUT_MS, + }); + + const zeilen = [message.body]; + if (message.url) zeilen.push("", message.url); + zeilen.push("", `— ${APP_NAME}, ${APP_URL}`); + + await transport.sendMail({ + from: config.from, + to: target.target, + subject: message.title, + text: zeilen.join("\n"), + }); + + return { ok: true, message: `An ${target.target} versendet` }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } +} + +/** Stellt eine Nachricht ueber den Weg des Ziels zu. */ +export async function deliver( + target: NotificationTarget, + message: NotificationMessage, +): Promise { + if (target.channel === "EMAIL") return sendEmail(target, message); + + // Webhook und ntfy rufen eine vom Benutzer angegebene Adresse auf. + try { + await assertAllowedUrl(target.target); + } catch (error) { + if (error instanceof BlockedHostError) return { ok: false, message: error.message }; + throw error; + } + + return target.channel === "NTFY" ? sendNtfy(target, message) : sendWebhook(target, message); +} diff --git a/src/lib/notify/index.ts b/src/lib/notify/index.ts new file mode 100644 index 0000000..267c390 --- /dev/null +++ b/src/lib/notify/index.ts @@ -0,0 +1,159 @@ +import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client"; +import { APP_NAME, APP_URL } from "@/lib/config"; +import { prisma } from "@/lib/db"; +import { deliver } from "./channels"; +import { EVENT_META, type NotificationMessage } from "./types"; + +export * from "./types"; +export { emailAvailable, smtpConfig } from "./channels"; + +/** + * Entscheidet nach einem Abgleich, worueber zu benachrichtigen ist, und + * stellt zu. + * + * Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem, + * worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit + * abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht. + * Der zuletzt gemeldete Zustand steht am Projekt. + * + * Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung + * darf den Abgleich nicht scheitern lassen. + */ +export async function notifyAfterSync(projectId: string): Promise { + try { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { snapshot: true }, + }); + if (!project?.ownerId) return; + + const faellig = pendingEvents(project, project.snapshot); + if (faellig.length === 0) return; + + const ziele = await prisma.notificationTarget.findMany({ + where: { + userId: project.ownerId, + isActive: true, + events: { hasSome: faellig.map((entry) => entry.event) }, + }, + }); + + // Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten + // eingerichteten Ziel gleich die gesamte Vergangenheit hinterher. + await prisma.project.update({ + where: { id: project.id }, + data: { + notifiedSyncError: project.syncError, + notifiedCiState: project.snapshot?.ciState ?? null, + notifiedRelease: currentRelease(project.snapshot), + }, + }); + + if (ziele.length === 0) return; + + for (const eintrag of faellig) { + const passende = ziele.filter((ziel) => ziel.events.includes(eintrag.event)); + for (const ziel of passende) { + const ergebnis = await deliver(ziel, eintrag.message); + await prisma.notificationTarget.update({ + where: { id: ziel.id }, + data: { + lastSentAt: new Date(), + lastOk: ergebnis.ok, + lastMessage: ergebnis.message, + }, + }); + } + } + } catch (error) { + console.error("[nebula] Benachrichtigung fehlgeschlagen:", error); + } +} + +function currentRelease(snapshot: RepoSnapshot | null): string | null { + if (!snapshot) return null; + return snapshot.latestRelease ?? snapshot.latestTag ?? null; +} + +/** + * Vergleicht den aktuellen Stand mit dem zuletzt gemeldeten. + * Exportiert, weil hier die eigentliche Entscheidung faellt – siehe Tests. + */ +export function pendingEvents( + project: Project, + snapshot: RepoSnapshot | null, +): Array<{ event: NotificationEvent; message: NotificationMessage }> { + const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = []; + const projektUrl = `${APP_URL}/projects/${project.id}`; + const bezug = { id: project.id, name: project.name, slug: project.slug }; + const jetzt = new Date().toISOString(); + + if (project.syncError && project.syncError !== project.notifiedSyncError) { + treffer.push({ + event: "SYNC_FAILED", + message: { + event: "SYNC_FAILED", + title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`, + body: project.syncError, + priority: "high", + url: projektUrl, + project: bezug, + at: jetzt, + }, + }); + } + + const ci = snapshot?.ciState ?? null; + if (ci === "failure" && ci !== project.notifiedCiState) { + treffer.push({ + event: "CI_FAILED", + message: { + event: "CI_FAILED", + title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`, + body: + `Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` + + `fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`, + priority: "high", + url: snapshot?.ciUrl ?? projektUrl, + project: bezug, + at: jetzt, + }, + }); + } + + const release = currentRelease(snapshot); + // Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere + // jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn + // vorher schon einmal etwas festgehalten wurde. + if (release && project.notifiedRelease && release !== project.notifiedRelease) { + treffer.push({ + event: "NEW_RELEASE", + message: { + event: "NEW_RELEASE", + title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`, + body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`, + priority: "normal", + url: snapshot?.htmlUrl ?? projektUrl, + project: bezug, + at: jetzt, + }, + }); + } + + return treffer; +} + +/** Nachricht fuer den Knopf "Testen" an einem Ziel. */ +export function testMessage(): NotificationMessage { + return { + event: "TEST", + title: `🔔 ${APP_NAME}: Testnachricht`, + body: + "Wenn du das liest, ist der Zustellweg eingerichtet. " + + "Echte Meldungen kommen nur bei einer Aenderung – nicht bei jedem Abgleich.", + priority: "normal", + url: APP_URL, + project: null, + at: new Date().toISOString(), + }; +} diff --git a/src/lib/notify/pending.test.ts b/src/lib/notify/pending.test.ts new file mode 100644 index 0000000..0dec677 --- /dev/null +++ b/src/lib/notify/pending.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { Project, RepoSnapshot } from "@prisma/client"; +import { pendingEvents } from "./index"; + +/** + * Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung + * gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit + * abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht. + */ + +function projekt(overrides: Partial = {}): Project { + return { + id: "p1", + name: "Testprojekt", + slug: "testprojekt", + syncError: null, + notifiedSyncError: null, + notifiedCiState: null, + notifiedRelease: null, + ...overrides, + } as Project; +} + +function snapshot(overrides: Partial = {}): RepoSnapshot { + return { + ciState: null, + ciBranch: "main", + ciName: null, + ciUrl: null, + latestTag: null, + latestRelease: null, + htmlUrl: null, + ...overrides, + } as RepoSnapshot; +} + +const events = (p: Project, s: RepoSnapshot | null) => + pendingEvents(p, s).map((entry) => entry.event); + +describe("Abgleichsfehler", () => { + it("meldet einen neuen Fehler", () => { + expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]); + }); + + it("meldet denselben Fehler kein zweites Mal", () => { + expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]); + }); + + it("meldet, wenn sich der Fehler aendert", () => { + expect( + events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null), + ).toEqual(["SYNC_FAILED"]); + }); + + it("meldet nichts, wenn kein Fehler vorliegt", () => { + expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]); + }); +}); + +describe("CI", () => { + it("meldet den Wechsel auf rot", () => { + expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]); + }); + + it("meldet dauerhaft rote CI nur einmal", () => { + expect( + events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })), + ).toEqual([]); + }); + + it("meldet nicht, wenn die CI gruen ist", () => { + expect( + events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })), + ).toEqual([]); + }); + + it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => { + expect( + events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })), + ).toEqual(["CI_FAILED"]); + }); +}); + +describe("Release", () => { + it("schweigt beim allerersten Abgleich", () => { + // Sonst waere jede bereits vorhandene Version eine "Neuigkeit". + expect( + events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })), + ).toEqual([]); + }); + + it("meldet eine neue Version", () => { + expect( + events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })), + ).toEqual(["NEW_RELEASE"]); + }); + + it("meldet dieselbe Version nicht erneut", () => { + expect( + events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })), + ).toEqual([]); + }); + + it("zieht den Releasenamen dem Tag vor", () => { + const treffer = pendingEvents( + projekt({ notifiedRelease: "alt" }), + snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }), + ); + expect(treffer[0].message.title).toContain("Zweite Ausgabe"); + }); +}); + +describe("mehrere Anlaesse gleichzeitig", () => { + it("meldet Fehler und rote CI zusammen", () => { + expect( + events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })), + ).toEqual(["SYNC_FAILED", "CI_FAILED"]); + }); + + it("haengt Projektbezug und Tieflink an jede Nachricht", () => { + const treffer = pendingEvents(projekt({ syncError: "404" }), null); + expect(treffer[0].message.project?.slug).toBe("testprojekt"); + expect(treffer[0].message.url).toContain("/projects/p1"); + expect(treffer[0].message.priority).toBe("high"); + }); +}); diff --git a/src/lib/notify/types.ts b/src/lib/notify/types.ts new file mode 100644 index 0000000..71e7b03 --- /dev/null +++ b/src/lib/notify/types.ts @@ -0,0 +1,66 @@ +import type { NotificationEvent } from "@prisma/client"; + +/** Reihenfolge fuer die Oberflaeche. */ +export const NOTIFICATION_EVENTS: NotificationEvent[] = [ + "SYNC_FAILED", + "CI_FAILED", + "NEW_RELEASE", +]; + +export const EVENT_META: Record< + NotificationEvent, + { label: string; description: string; emoji: string } +> = { + SYNC_FAILED: { + label: "Abgleich fehlgeschlagen", + description: "Das Git-System war nicht erreichbar oder hat abgelehnt.", + emoji: "⚠️", + }, + CI_FAILED: { + label: "CI rot", + description: "Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen.", + emoji: "🔴", + }, + NEW_RELEASE: { + label: "Neues Release", + description: "Im Repository ist ein neuer Tag oder ein Release aufgetaucht.", + emoji: "🏷️", + }, +}; + +/** Eine fertig formulierte Nachricht, unabhaengig vom Zustellweg. */ +export interface NotificationMessage { + event: NotificationEvent | "TEST"; + title: string; + body: string; + /** Fuer ntfy: hoehere Zahl bedeutet auffaelliger. */ + priority: "normal" | "high"; + /** Link zurueck ins Dashboard oder ins Repository. */ + url: string | null; + project: { id: string; name: string; slug: string } | null; + at: string; +} + +export interface DeliveryResult { + ok: boolean; + message: string; +} + +/** + * Felder eines Ziels, die an den Browser gehen duerfen – ohne Chiffrat. + * Liegt hier und nicht in der Route: Next laesst in route.ts nur bestimmte + * Exporte zu und bricht den Bau bei allem anderen ab. + */ +export const targetSafeSelect = { + id: true, + name: true, + channel: true, + target: true, + secretHint: true, + events: true, + isActive: true, + lastSentAt: true, + lastOk: true, + lastMessage: true, + createdAt: true, +} as const; diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index 1c8072d..2842e33 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -1,6 +1,7 @@ import type { Project } from "@prisma/client"; import { prisma } from "@/lib/db"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; +import { notifyAfterSync } from "@/lib/notify"; import type { RepoSnapshotData } from "@/lib/git/types"; export interface SyncResult { @@ -54,6 +55,8 @@ export async function syncProject(projectId: string): Promise { try { const data = await client.fetchRepo(ref); await persistSnapshot(project.id, data); + // Nach dem Speichern, damit die Meldung den neuen Stand sieht. + await notifyAfterSync(project.id); return { ok: true, message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, @@ -70,6 +73,7 @@ export async function syncProject(projectId: string): Promise { syncFailureCount: { increment: 1 }, }, }); + await notifyAfterSync(project.id); return { ok: false, message }; } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 7dc5440..4c2eb95 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -170,6 +170,40 @@ export const adminUserUpdateSchema = z.object({ password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()), }); +// ── Benachrichtigungen ─────────────────────────────────────── + +const CHANNELS = ["WEBHOOK", "NTFY", "EMAIL"] as const; +const EVENTS = ["SYNC_FAILED", "CI_FAILED", "NEW_RELEASE"] as const; + +export const notificationTargetCreateSchema = z + .object({ + name: z.string().trim().min(1, "Name darf nicht leer sein").max(60), + channel: z.enum(CHANNELS), + target: z.string().trim().min(1, "Ziel fehlt").max(500), + /** Bearer-Token fuer ntfy oder Signaturschluessel fuer den Webhook. */ + secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()), + events: z.array(z.enum(EVENTS)).min(1, "Mindestens ein Anlass"), + isActive: z.boolean().default(true), + }) + .refine( + (value) => + value.channel === "EMAIL" + ? /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.target) + : /^https?:\/\//i.test(value.target), + { + message: "E-Mail-Adresse bzw. URL mit http:// oder https:// erwartet", + path: ["target"], + }, + ); + +export const notificationTargetUpdateSchema = z.object({ + name: z.string().trim().min(1).max(60).optional(), + target: z.string().trim().min(1).max(500).optional(), + secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()), + events: z.array(z.enum(EVENTS)).min(1).optional(), + isActive: z.boolean().optional(), +}); + export const passkeyNameSchema = z.object({ name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()), });