Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bfed6d9f6 | ||
|
|
d1b9dfe535 | ||
|
|
ebea3786b1 |
@@ -0,0 +1,78 @@
|
||||
# Prueft jeden Push und jeden Pull Request.
|
||||
#
|
||||
# Die Datei liegt unter .gitea/workflows und wird von Gitea Actions gelesen.
|
||||
# Die Syntax ist mit GitHub Actions kompatibel – wer das Repo dorthin
|
||||
# spiegelt, kopiert sie nach .github/workflows.
|
||||
#
|
||||
# Voraussetzung: In der Gitea-Instanz muessen Actions aktiviert sein
|
||||
# (app.ini: [actions] ENABLED = true) und mindestens ein Runner registriert.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
pruefen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: nebula
|
||||
POSTGRES_PASSWORD: nebula
|
||||
# Der Name muss "test" enthalten – global-setup.ts leert die
|
||||
# Datenbank und weigert sich bei allem anderen.
|
||||
POSTGRES_DB: nebula_e2e_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U nebula"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
|
||||
E2E_DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
|
||||
# Nur fuer den Lauf – keine echten Daten im Spiel.
|
||||
APP_SECRET: ci-lauf-schluessel-nur-fuer-die-pipeline-0123456789
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Abhaengigkeiten
|
||||
run: npm ci
|
||||
|
||||
- name: Typen pruefen
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Unit-Tests
|
||||
run: npm test
|
||||
|
||||
- name: Browser fuer die Oberflaechentests
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# Baut zuerst und startet den Server dann selbst (siehe
|
||||
# playwright.config.ts, webServer).
|
||||
- name: Ende-zu-Ende-Tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Bericht bei Fehlschlag sichern
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-bericht
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
+38
-5
@@ -402,8 +402,10 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
|
||||
|
||||
### 6.4 Tests
|
||||
|
||||
`npm test` führt die Unit-Tests aus — reine Logik, kein DOM, keine Datenbank, unter einer
|
||||
Sekunde:
|
||||
Zwei Ebenen. `npm run test:all` führt beide aus.
|
||||
|
||||
**`npm test` — Unit-Tests** (Vitest, 56 Stück, unter einer Sekunde, ohne DOM und ohne
|
||||
Datenbank):
|
||||
|
||||
| Bereich | Was geprüft wird |
|
||||
| --- | --- |
|
||||
@@ -411,11 +413,42 @@ Sekunde:
|
||||
| `git/parse` | URL-Zerlegung für https, SSH, verschachtelte GitLab-Gruppen und Oberflächenpfade; Provider-Erkennung; Bildung der Aktivitätskurve |
|
||||
| `security/ssrf` | Sperrlisten für Loopback, Link-Local, Metadaten-Endpunkt, CGNAT und Multicast; Formprüfung; die drei Umgebungsschalter |
|
||||
| `auth/password` | scrypt-Rundlauf, unterschiedliches Salz, Ablageformat, Verhalten bei kaputten Hashes, Passwortregeln |
|
||||
| `tasks` | Berechnung des abgeleiteten Fortschritts, Spaltenreihenfolge |
|
||||
|
||||
Bewusst ohne Datenbank- und Browsertests: die brauchen Infrastruktur und gehören in eine
|
||||
CI-Pipeline. Das steht in der [ROADMAP](ROADMAP.md).
|
||||
**`npm run test:e2e` — Ende-zu-Ende** (Playwright, 13 Stück, rund 20 Sekunden). Läuft
|
||||
gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 — also durch Middleware,
|
||||
Authentifizierung, Validierung und Datenbank hindurch:
|
||||
|
||||
### 6.5 API
|
||||
| Datei | Was geprüft wird |
|
||||
| --- | --- |
|
||||
| `e2e/api.spec.ts` | Einrichtung sperrt sich nach dem ersten Konto · kein Zugriff ohne Anmeldung · fremdes Origin und falscher Content-Type abgewiesen · SSRF geblockt · **Datentrennung zwischen zwei Konten** · Aufgaben-Reihenfolge, abgeleiteter Fortschritt und untergeschobene IDs · Export ohne Geheimnisse, Import je Strategie |
|
||||
| `e2e/ui.spec.ts` | Anmelden · Projekt anlegen · Aufgaben anlegen und **per Ziehen verschieben** (inklusive Prüfung nach dem Neuladen) · Volltextsuche · Abmelden sperrt geschützte Seiten wieder |
|
||||
|
||||
Zwei Dinge sind bewusst so gebaut:
|
||||
|
||||
- **Die Testdatenbank kann nicht die echte sein.** `e2e/global-setup.ts` bricht ab, wenn
|
||||
der Datenbankname nicht `test` enthält — vor dem `TRUNCATE`. Der teuerste denkbare
|
||||
Fehler wäre eine Testreihe, die die Produktivdaten leert.
|
||||
- **Playwright startet den Server selbst** (`webServer` in `playwright.config.ts`) mit
|
||||
eigener `DATABASE_URL` und eigenem Port. Eine laufende Instanz auf 3000 stört nicht.
|
||||
|
||||
Einmalig vorbereiten:
|
||||
|
||||
```bash
|
||||
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
|
||||
```
|
||||
|
||||
### 6.5 Fortlaufende Prüfung
|
||||
|
||||
`.gitea/workflows/ci.yml` prüft jeden Push und jeden Pull Request: Typen, Unit-Tests,
|
||||
Bau und Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlägt etwas fehl,
|
||||
wird der Playwright-Bericht als Artefakt abgelegt.
|
||||
|
||||
Dafür müssen in der Gitea-Instanz Actions aktiviert sein (`app.ini`: `[actions] ENABLED
|
||||
= true`) und ein Runner registriert. Die Syntax ist GitHub-kompatibel — für GitHub die
|
||||
Datei nach `.github/workflows/` kopieren.
|
||||
|
||||
### 6.6 API
|
||||
|
||||
Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health.
|
||||
Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
|
||||
|
||||
@@ -301,6 +301,9 @@ nebula/
|
||||
| `npm test` | Unit-Tests (Vitest), unter einer Sekunde |
|
||||
| `npm run test:watch` | Tests im Beobachtungsmodus |
|
||||
| `npm run typecheck` | TypeScript prüfen |
|
||||
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
|
||||
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
|
||||
| `npm run test:all` | Beides nacheinander |
|
||||
| `npm run docker:db` | Nur die Datenbank starten |
|
||||
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
|
||||
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
|
||||
|
||||
+2
-14
@@ -11,20 +11,7 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||
|
||||
## 1 · Als Nächstes empfohlen
|
||||
|
||||
### 1.1 Tests ausbauen: Datenbank und Browser `L`
|
||||
|
||||
Das Fundament steht — 51 Unit-Tests der reinen Logik laufen in unter einer
|
||||
Sekunde. Was fehlt, ist die Schicht darüber:
|
||||
|
||||
- **Integrationstests** gegen eine Wegwerf-Datenbank: Datentrennung zwischen
|
||||
Konten, Import-Strategien, Backoff des Abgleichs
|
||||
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung, Kanban
|
||||
- **CI-Pipeline** bei GitHub oder Gitea Actions
|
||||
|
||||
Ich prüfe bislang alles von Hand, inklusive Screenshots. Das trägt nicht
|
||||
dauerhaft und macht jede weitere Änderung teurer als nötig.
|
||||
|
||||
### 1.2 Benachrichtigungen `M`
|
||||
### 1.1 Benachrichtigungen `M`
|
||||
|
||||
Bei Sync-Fehlern, roter CI, neuen Releases oder Projekten, die lange
|
||||
stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
|
||||
@@ -129,6 +116,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
|
||||
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
|
||||
| **v0.5.0** Aufgabenbrett je Projekt mit sortierbaren Spalten, Fortschritt aus Aufgaben | ✓ |
|
||||
| **v0.6.0** Ende-zu-Ende-Tests mit Playwright, CI-Pipeline für Gitea Actions | ✓ |
|
||||
|
||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||
|
||||
Generated
+23
-2
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
@@ -108,6 +108,9 @@ function UserMenu({ user }: { user: SessionUser }) {
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
// Auf schmalen Anzeigen bleibt nur das Kuerzel sichtbar – ohne
|
||||
// Beschriftung liest ein Screenreader dann nur zwei Buchstaben vor.
|
||||
aria-label={`Benutzermenue fuer ${label}`}
|
||||
>
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
||||
{initials}
|
||||
|
||||
@@ -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<string, string>,
|
||||
): Promise<DeliveryResult> {
|
||||
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<DeliveryResult> {
|
||||
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<string, string> = { "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<DeliveryResult> {
|
||||
const headers: Record<string, string> = {
|
||||
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} <nebula@localhost>`,
|
||||
};
|
||||
}
|
||||
|
||||
export const emailAvailable = () => smtpConfig() !== null;
|
||||
|
||||
async function sendEmail(
|
||||
target: NotificationTarget,
|
||||
message: NotificationMessage,
|
||||
): Promise<DeliveryResult> {
|
||||
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<DeliveryResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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> = {}): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Testprojekt",
|
||||
slug: "testprojekt",
|
||||
syncError: null,
|
||||
notifiedSyncError: null,
|
||||
notifiedCiState: null,
|
||||
notifiedRelease: null,
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<RepoSnapshot> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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<SyncResult> {
|
||||
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<SyncResult> {
|
||||
syncFailureCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
await notifyAfterSync(project.id);
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user