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()), });