8 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 a237a79915 docs: Stillstandsmeldung dokumentieren, Version 0.8.0
CI / pruefen (push) Has been cancelled
FEATURES erklaert, warum der Massstab der letzte Commit ist und nicht das
Aenderungsdatum, wen die Pruefung auslaesst und wann erneut gemeldet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 18ed4de9f9 feat(admin): Schwelle fuer den Stillstand einstellbar
Eigener Abschnitt mit der Schwelle von 14 Tagen bis zu einem Jahr, dem
Zeitpunkt der letzten Pruefung und der Zahl der Empfaenger. Steht die auf
null, sagt der Abschnitt das und verweist auf "Mein Konto".

Eine Aenderung der Schwelle setzt lastStaleCheckAt zurueck, damit sie beim
naechsten Tick greift und nicht erst in 24 Stunden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 4d1389c3a8 feat(notify): stillstehende Projekte melden
Vierter Anlass: seit laengerem kein Commit. Er haengt an keinem Ereignis -
es passiert ja gerade nichts - und braucht deshalb einen eigenen Durchlauf
im Zeitgeber, hoechstens einmal am Tag und unabhaengig vom Abgleich. Hoert
niemand auf den Anlass, laeuft die Pruefung gar nicht erst.

Massstab ist ausschliesslich der letzte Commit im verknuepften Repository.
Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und
wuerde den Stillstand immer wieder zuruecksetzen. Projekte ohne Repository
bleiben deshalb aussen vor, ebenso fertige und archivierte.

Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit
kam und es seitdem wieder still wurde - ein Projekt, das aufwacht und wieder
einschlaeft, meldet sich also ein zweites Mal.

Beim Testen kam heraus, dass ich den neuen Anlass zwar ins Prisma-Schema und
in die Oberflaeche eingetragen hatte, nicht aber ins Zod-Schema - die API
lehnte ihn mit 422 ab. Behoben, und ein Test haelt beide Listen kuenftig
zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 252c0686df docs: Benachrichtigungen dokumentieren, Version 0.7.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt die drei Anlaesse, die Entprellung samt ihrer beiden
Feinheiten, die drei Zustellwege und die Absicherung. README bekommt die
neuen Endpunkte und Umgebungsvariablen, .env.example die SMTP-Vorlage.

ROADMAP: Benachrichtigungen erledigt. Neu an erster Stelle steht das Melden
stillstehender Projekte - das ist der einzige Anlass, der kein Ereignis hat,
an dem er haengt, und braucht deshalb einen eigenen Durchlauf. Ein doppelter
Eintrag zu Webhooks statt Polling ist dabei aufgefallen und entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:23:06 +02:00
florian.wildeandClaude Opus 5 f1e4271488 feat(ui): Benachrichtigungsziele unter "Mein Konto"
Beliebig viele Ziele je Konto, jedes mit eigenen Anlaessen. Jedes laesst sich
einzeln testen und pausieren; Ergebnis und Zeitpunkt der letzten Zustellung
stehen an der Karte.

Der Weg laesst sich nach dem Anlegen nicht mehr aendern - ein Webhook-Ziel zu
einer E-Mail-Adresse umzubiegen ergaebe keinen Sinn und muesste das
Geheimnis mitwandern lassen. Ist kein SMTP hinterlegt, ist die Auswahl
"E-Mail" gesperrt und erklaert, warum.

targetSafeSelect liegt in lib/notify/types statt in der Route: Next laesst in
route.ts nur bestimmte Exporte zu und bricht den Bau bei allem anderen ab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 3bfed6d9f6 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) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 d1b9dfe535 docs: Testebenen dokumentieren, Version 0.6.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt beide Ebenen - Unit-Tests ohne Datenbank und
Ende-zu-Ende-Tests gegen die gebaute Anwendung - samt der beiden
Vorkehrungen gegen einen Lauf auf der echten Datenbank. Dazu die
Voraussetzungen fuer Gitea Actions.

ROADMAP: der Testausbau war Punkt 1 und ist erledigt; Benachrichtigungen
ruecken auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 ebea3786b1 ci: Pipeline fuer Gitea Actions
Prueft jeden Push und jeden Pull Request: Typen, Unit-Tests, Bau und
Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlaegt etwas
fehl, wird der Playwright-Bericht als Artefakt abgelegt.

Der Datenbankname des Dienstes enthaelt "test", sonst verweigert
global-setup.ts den Dienst. In CI wird das mitgelieferte Chromium benutzt
statt des oertlich installierten Chrome.

Die Syntax ist GitHub-kompatibel - fuer GitHub genuegt es, die Datei nach
.github/workflows zu kopieren.

Nebenbei bekommt der Knopf des Benutzermenues eine Beschriftung. Ohne sie
las ein Screenreader auf schmalen Anzeigen nur das zweibuchstabige Kuerzel
vor, und im Test war er nicht eindeutig vom Datenmenue zu unterscheiden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
30 changed files with 2186 additions and 42 deletions
+15
View File
@@ -56,6 +56,21 @@ APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen.
SESSION_TTL_DAYS="30"
# ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet.
#
# E-Mail steht erst zur Verfuegung, wenn SMTP_HOST gesetzt ist:
# SMTP_HOST="smtp.example.com"
# SMTP_PORT="587"
# SMTP_SECURE="false" # true bei Port 465, sonst STARTTLS
# SMTP_USER="nebula@example.com"
# SMTP_PASSWORD="geheim"
# SMTP_FROM="Nebula <nebula@example.com>"
# Timeout fuer das Zustellen in Millisekunden
NOTIFY_TIMEOUT_MS="10000"
# ── Ausgehende Git-Zugriffe ──────────────────────────────────
# Timeout fuer Git-API-Aufrufe in Millisekunden
GIT_API_TIMEOUT_MS="15000"
+78
View File
@@ -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
+111 -5
View File
@@ -322,6 +322,75 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
---
## 4b. Benachrichtigungen
Einzurichten unter *Mein Konto → Benachrichtigungen*, pro Konto und beliebig viele Ziele.
### Anlässe
| Anlass | Wann |
| --- | --- |
| ⚠️ **Abgleich fehlgeschlagen** | Das Git-System war nicht erreichbar oder hat abgelehnt |
| 🔴 **CI rot** | Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen |
| 🏷️ **Neues Release** | Ein neuer Tag oder ein neues Release ist aufgetaucht |
| 🕸️ **Projekt steht still** | Seit längerem kein Commit — Schwelle einstellbar, Vorgabe 60 Tage |
### Nur bei Änderung
Der entscheidende Punkt: Gemeldet wird **ausschließlich, wenn sich etwas ändert**
gegenüber dem, worüber zuletzt benachrichtigt wurde. Ein Repository mit abgelaufenem
Token meldet sich also **einmal**, nicht bei jedem stündlichen Durchlauf. Wird der Fehler
ein anderer, kommt eine neue Meldung; ist er behoben und tritt später erneut auf, ebenso.
Zwei Feinheiten:
- **Beim allerersten Abgleich** eines Repositories wird kein Release gemeldet — sonst
wäre jede bereits vorhandene Version eine „Neuigkeit".
- Der Merkstand wird auch dann fortgeschrieben, wenn **kein** Ziel eingerichtet ist. Sonst
käme beim ersten eingerichteten Ziel die ganze Vergangenheit hinterher.
### Stillstand
Der einzige Anlass, der an keinem Ereignis hängt — es passiert ja gerade nichts. Deshalb
ein eigener Durchlauf, **höchstens einmal am Tag**, unabhängig vom automatischen Abgleich.
Hört niemand auf diesen Anlass, läuft die Prüfung gar nicht erst.
Maßstab ist ausschließlich der **letzte Commit** im verknüpften Repository. Das
Änderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und würde den
Stillstand immer wieder zurücksetzen. Projekte **ohne Repository** bleiben deshalb außen
vor, ebenso fertige und archivierte.
Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit kam und es
seitdem wieder still geworden ist — ein Projekt, das aufwacht und wieder einschläft, meldet
sich also ein zweites Mal.
Die Schwelle setzt ein Administrator unter *Admin-Einstellungen → Stillstand* (14 Tage bis
ein Jahr). Eine Änderung wirkt sofort, nicht erst nach 24 Stunden.
### Zustellwege
| Weg | Details |
| --- | --- |
| **ntfy** | Vollständige Themen-URL. Titel, Priorität und Symbol als Kopfzeilen; optionales Bearer-Token für geschützte Themen. Läuft auch gegen ein selbst gehostetes ntfy im eigenen Netz. |
| **Webhook** | JSON per POST mit Anlass, Titel, Text, Projektbezug und Tieflink. Mit hinterlegtem Geheimnis wird der Rumpf per HMAC-SHA256 signiert (`X-Nebula-Signature`), damit der Empfänger die Herkunft prüfen kann. |
| **E-Mail** | Über SMTP. Steht erst zur Verfügung, wenn `SMTP_HOST` in der `.env` gesetzt ist — sonst ist die Auswahl in der Oberfläche gesperrt und erklärt, warum. |
Jedes Ziel lässt sich einzeln **testen** und **pausieren**; Ergebnis und Zeitpunkt der
letzten Zustellung stehen an der Karte.
### Absicherung
- Zieladressen sind Benutzereingaben, die der Server abruft — sie laufen deshalb durch
denselben **SSRF-Schutz** wie die Git-Verbindungen. Ein ntfy im eigenen Netz bleibt
erlaubt, Loopback und Cloud-Metadaten nicht.
- Redirects werden **nicht verfolgt**: ein Umleitungsziel wäre nicht geprüft.
- Geheimnisse liegen AES-256-GCM-verschlüsselt und verlassen den Server nie; die
Oberfläche zeigt nur einen Hinweis wie `test…mnis`.
- Eine fehlgeschlagene Zustellung lässt den Abgleich nicht scheitern — der Fehler landet
am Ziel, nicht im Sync.
---
## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
@@ -349,6 +418,7 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
@@ -402,8 +472,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 +483,45 @@ 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 |
| `notify/pending` | Wann eine Benachrichtigung fällig ist: neuer Fehler ja, gleicher Fehler nein; CI-Wechsel nach rot; Release erst ab dem zweiten Abgleich |
| `notify/stale` | Stillstandsschwelle, keine Zweitmeldung, aber erneute Meldung nach zwischenzeitlicher Aktivität |
| `validation` | Dass jeder Anlass, den die Oberfläche anbietet, auch vom Schema akzeptiert wird — entstanden aus genau diesem Fehler |
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
+8
View File
@@ -127,6 +127,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
| `NOTIFY_TIMEOUT_MS` | Timeout beim Zustellen von Benachrichtigungen | `10000` |
| `SMTP_HOST` … `SMTP_FROM` | SMTP-Zugang; ohne `SMTP_HOST` steht der Weg „E-Mail" nicht zur Verfügung | leer |
`APP_NAME`, `APP_TAGLINE` und `APP_URL` werden **zur Laufzeit** gelesen. Dasselbe
Docker-Image lässt sich also ohne Neubau umbenennen oder unter einer anderen Adresse
@@ -301,6 +303,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` |
@@ -335,6 +340,9 @@ ausschließlich Daten des angemeldeten Kontos.
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
+10 -27
View File
@@ -11,44 +11,24 @@ 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`
Bei Sync-Fehlern, roter CI, neuen Releases oder Projekten, die lange
stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
dem automatischen Abgleich und dem CI-Status ergibt das ein brauchbares
Frühwarnsystem — die Daten liegen bereits vor.
---
## 2 · Danach
### 2.1 Webhooks statt Polling `M`
### 1.1 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
### 2.2 Commit-Aktivität als Heatmap `S`
---
## 2 · Danach
### 2.1 Commit-Aktivität als Heatmap `S`
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.3 Kanban mit freier Reihenfolge `M`
### 2.2 Kanban mit freier Reihenfolge `M`
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
@@ -129,6 +109,9 @@ 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 | ✓ |
| **v0.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ |
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
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
+7
View File
@@ -59,6 +59,13 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000}
NOTIFY_TIMEOUT_MS: ${NOTIFY_TIMEOUT_MS:-10000}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
GIT_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false}
GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false}
GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-}
+23 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "0.6.0",
"version": "0.8.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;
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "NotificationEvent" ADD VALUE 'PROJECT_STALE';
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "lastStaleCheckAt" TIMESTAMP(3),
ADD COLUMN "staleAfterDays" INTEGER NOT NULL DEFAULT 60;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "notifiedStaleAt" TIMESTAMP(3);
+66
View File
@@ -33,6 +33,25 @@ 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
/// Seit laengerem kein Commit mehr – der sanfte Stups.
PROJECT_STALE
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
@@ -78,6 +97,7 @@ model User {
credentials Credential[]
projects Project[]
connections GitConnection[]
notificationTargets NotificationTarget[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -163,6 +183,12 @@ model AppSettings {
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json?
// ── Stillstand ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60)
/// Die Pruefung laeuft hoechstens einmal am Tag.
lastStaleCheckAt DateTime?
updatedAt DateTime @updatedAt
}
@@ -249,6 +275,16 @@ 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?
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
@@ -263,6 +299,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())
+27 -1
View File
@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
import {
NotificationSection,
type NotificationTargetEntry,
} from "@/components/account/NotificationSection";
import { emailAvailable } from "@/lib/notify";
import { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
@@ -13,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AccountPage() {
const user = await requireUserPage();
const [passkeys, sessions] = await Promise.all([
const [passkeys, sessions, targets] = await Promise.all([
prisma.credential.findMany({
where: { userId: user.id },
select: {
@@ -37,6 +42,22 @@ export default async function AccountPage() {
},
orderBy: { lastSeenAt: "desc" },
}),
prisma.notificationTarget.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
},
orderBy: { name: "asc" },
}),
]);
const data: AccountData = {
@@ -60,6 +81,11 @@ export default async function AccountPage() {
</header>
<AccountManager data={data} />
<NotificationSection
targets={targets as unknown as NotificationTargetEntry[]}
emailAvailable={emailAvailable()}
/>
</div>
);
}
+9 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() {
const admin = await requireAdminPage();
const [settings, users, eligible] = await Promise.all([
const [settings, users, eligible, staleListeners] = await Promise.all([
getAppSettings(),
prisma.user.findMany({
select: userSafeSelect,
@@ -36,6 +36,9 @@ export default async function AdminPage() {
],
},
}),
prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
}),
]);
const data: AdminData = {
@@ -54,6 +57,11 @@ export default async function AdminPage() {
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
eligible,
},
stale: {
afterDays: settings.staleAfterDays,
lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners,
},
};
return (
+6
View File
@@ -37,6 +37,7 @@ export async function PATCH(request: Request) {
autoSyncIntervalMinutes,
autoSyncActiveOnly,
autoSyncBatchSize,
staleAfterDays,
} = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
@@ -57,6 +58,11 @@ export async function PATCH(request: Request) {
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
// Neue Schwelle: die Pruefung soll beim naechsten Tick greifen, nicht
// erst in 24 Stunden.
...(staleAfterDays !== undefined
? { staleAfterDays, lastStaleCheckAt: null }
: {}),
};
const settings = await prisma.appSettings.upsert({
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { targetSafeSelect } from "@/lib/notify/types";
import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!existing) return jsonError("Ziel nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined) update.name = data.name;
if (data.events !== undefined) update.events = data.events;
if (data.isActive !== undefined) update.isActive = data.isActive;
if (data.target !== undefined) {
if (existing.channel !== "EMAIL") {
try {
await assertAllowedUrl(data.target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
update.target = data.target;
}
// Leeres Feld heisst "unveraendert", null heisst "entfernen".
if (data.secret !== undefined) {
if (data.secret) {
update.secretCipher = encryptSecret(data.secret);
update.secretHint = tokenHint(data.secret);
} else {
update.secretCipher = null;
update.secretHint = null;
}
}
try {
const target = await prisma.notificationTarget.update({
where: { id },
data: update,
select: targetSafeSelect,
});
return NextResponse.json({ target });
} catch {
return jsonError("Name bereits vergeben.", 409);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const result = await prisma.notificationTarget.deleteMany({
where: { id, userId: auth.user.id },
});
if (result.count === 0) return jsonError("Ziel nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { deliver } from "@/lib/notify/channels";
import { testMessage } from "@/lib/notify";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Schickt eine Testnachricht an genau dieses Ziel. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Loest einen ausgehenden Aufruf aus – entsprechend begrenzt.
const limited = rateLimit(
`notify-test:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const target = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!target) return jsonError("Ziel nicht gefunden", 404);
const result = await deliver(target, testMessage());
await prisma.notificationTarget.update({
where: { id },
data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message },
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { targetSafeSelect } from "@/lib/notify/types";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const targets = await prisma.notificationTarget.findMany({
where: { userId: auth.user.id },
select: targetSafeSelect,
orderBy: { name: "asc" },
});
return NextResponse.json({ targets });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { name, channel, target, secret, events, isActive } = parsed.data;
// Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung
// wie bei den Git-Verbindungen.
if (channel !== "EMAIL") {
try {
await assertAllowedUrl(target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
const duplicate = await prisma.notificationTarget.findFirst({
where: { userId: auth.user.id, name },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
const created = await prisma.notificationTarget.create({
data: {
userId: auth.user.id,
name,
channel,
target,
secretCipher: secret ? encryptSecret(secret) : null,
secretHint: secret ? tokenHint(secret) : null,
events,
isActive,
},
select: targetSafeSelect,
});
return NextResponse.json({ target: created }, { status: 201 });
}
+3
View File
@@ -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,515 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Bell,
BellRing,
CheckCircle2,
KeyRound,
Loader2,
Mail,
Pencil,
Plus,
Trash2,
Webhook,
XCircle,
} from "lucide-react";
import type { NotificationChannel, NotificationEvent } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { EVENT_META, NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { cn, formatRelative } from "@/lib/utils";
export interface NotificationTargetEntry {
id: string;
name: string;
channel: NotificationChannel;
target: string;
secretHint: string | null;
events: NotificationEvent[];
isActive: boolean;
lastSentAt: string | Date | null;
lastOk: boolean | null;
lastMessage: string | null;
}
const CHANNEL_META: Record<
NotificationChannel,
{ label: string; icon: typeof Webhook; hint: string; placeholder: string }
> = {
NTFY: {
label: "ntfy",
icon: BellRing,
hint: "Vollstaendige Themen-URL, z. B. https://ntfy.sh/mein-geheimes-thema",
placeholder: "https://ntfy.sh/mein-thema",
},
WEBHOOK: {
label: "Webhook",
icon: Webhook,
hint: "Bekommt JSON per POST. Mit Geheimnis wird der Rumpf signiert (X-Nebula-Signature).",
placeholder: "https://mein-dienst.de/haken",
},
EMAIL: {
label: "E-Mail",
icon: Mail,
hint: "Braucht SMTP-Zugangsdaten in der .env der Instanz.",
placeholder: "ich@example.com",
},
};
export function NotificationSection({
targets,
emailAvailable,
}: {
targets: NotificationTargetEntry[];
emailAvailable: boolean;
}) {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTargetEntry | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null);
async function test(entry: NotificationTargetEntry) {
setBusyId(entry.id);
setNotice(null);
try {
const response = await fetch(`/api/notifications/${entry.id}/test`, { method: "POST" });
const body = await response.json().catch(() => ({}));
setNotice({ ok: response.ok, text: body.message ?? body.error ?? "Unbekannte Antwort." });
router.refresh();
} finally {
setBusyId(null);
}
}
async function remove(entry: NotificationTargetEntry) {
if (!window.confirm(`Ziel „${entry.name}“ entfernen?`)) return;
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setBusyId(null);
router.refresh();
}
async function toggle(entry: NotificationTargetEntry) {
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !entry.isActive }),
}).catch(() => undefined);
setBusyId(null);
router.refresh();
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Bell className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Benachrichtigungen
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Meldung bei fehlgeschlagenem Abgleich, roter CI oder neuem Release. Gemeldet
wird nur bei einer <strong className="text-slate-300">Aenderung</strong> –
nicht bei jedem Durchlauf.
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
Ziel hinzufuegen
</button>
</div>
{targets.length === 0 ? (
<p className="rounded-xl border border-dashed border-white/8 px-4 py-6 text-center text-sm text-slate-500">
Noch kein Ziel eingerichtet.
</p>
) : (
<div className="space-y-2">
{targets.map((entry) => {
const meta = CHANNEL_META[entry.channel];
const Icon = meta.icon;
return (
<div key={entry.id} className="glass-soft px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
<Icon className="h-3.5 w-3.5 shrink-0 text-violet-300" />
{entry.name}
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
{meta.label}
</span>
{!entry.isActive ? (
<span className="rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-400">
pausiert
</span>
) : null}
{entry.secretHint ? (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-amber-300/70">
<KeyRound className="h-2.5 w-2.5" />
{entry.secretHint}
</span>
) : null}
</p>
<p className="mt-0.5 truncate font-mono text-[11px] text-slate-500">
{entry.target}
</p>
<p className="mt-1.5 flex flex-wrap gap-1">
{entry.events.map((event) => (
<span
key={event}
className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400"
>
{EVENT_META[event].emoji} {EVENT_META[event].label}
</span>
))}
</p>
{entry.lastSentAt ? (
<p
className={cn(
"mt-1.5 flex items-center gap-1.5 text-[11px]",
entry.lastOk ? "text-emerald-200/80" : "text-rose-200/80",
)}
suppressHydrationWarning
>
{entry.lastOk ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{formatRelative(entry.lastSentAt)} · {entry.lastMessage}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{busyId === entry.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => test(entry)}
>
Testen
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => toggle(entry)}
title={entry.isActive ? "Pausieren" : "Fortsetzen"}
>
{entry.isActive ? "Pause" : "An"}
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
onClick={() => {
setEditing(entry);
setDialogOpen(true);
}}
aria-label="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
onClick={() => remove(entry)}
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
{!emailAvailable ? (
<p className="mt-3 text-[11px] text-slate-600">
E-Mail steht nicht bereit: In der <code className="text-violet-300">.env</code> der
Instanz fehlt <code className="text-violet-300">SMTP_HOST</code>. ntfy und Webhook
funktionieren ohne weitere Einrichtung.
</p>
) : null}
<TargetDialog
open={dialogOpen}
entry={editing}
emailAvailable={emailAvailable}
onClose={() => {
setDialogOpen(false);
setEditing(null);
}}
/>
</section>
);
}
function TargetDialog({
open,
entry,
emailAvailable,
onClose,
}: {
open: boolean;
entry: NotificationTargetEntry | null;
emailAvailable: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [channel, setChannel] = useState<NotificationChannel>("NTFY");
const [target, setTarget] = useState("");
const [secret, setSecret] = useState("");
const [events, setEvents] = useState<NotificationEvent[]>(["SYNC_FAILED", "CI_FAILED"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(entry);
useEffect(() => {
if (!open) return;
setName(entry?.name ?? "");
setChannel(entry?.channel ?? "NTFY");
setTarget(entry?.target ?? "");
setSecret("");
setEvents(entry?.events ?? ["SYNC_FAILED", "CI_FAILED"]);
setError(null);
setFieldErrors({});
}, [open, entry]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldErrors({});
const body: Record<string, unknown> = { name, target, events };
if (!isEdit) body.channel = channel;
if (!isEdit || secret.trim()) body.secret = secret;
try {
const response = await fetch(
isEdit ? `/api/notifications/${entry?.id}` : "/api/notifications",
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
}
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
const meta = CHANNEL_META[channel];
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Ziel bearbeiten" : "Neues Benachrichtigungsziel"}
description={
isEdit ? "Der Zustellweg selbst laesst sich nicht mehr aendern." : undefined
}
>
<form onSubmit={submit} className="space-y-4">
{!isEdit ? (
<div>
<span className="field-label">Weg</span>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(CHANNEL_META) as NotificationChannel[]).map((value) => {
const option = CHANNEL_META[value];
const Icon = option.icon;
const disabled = value === "EMAIL" && !emailAvailable;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => setChannel(value)}
title={disabled ? "Kein SMTP in der .env hinterlegt" : undefined}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
channel === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<Icon className="h-3.5 w-3.5" />
{option.label}
</span>
</button>
);
})}
</div>
</div>
) : null}
<div>
<label className="field-label" htmlFor="notify-name">
Name *
</label>
<input
id="notify-name"
className="field"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Handy"
required
maxLength={60}
/>
{fieldErrors.name ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.name}</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="notify-target">
{channel === "EMAIL" ? "E-Mail-Adresse *" : "URL *"}
</label>
<input
id="notify-target"
className="field font-mono text-xs"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={meta.placeholder}
required
/>
<p className="mt-1 text-[11px] text-slate-500">{meta.hint}</p>
{fieldErrors.target ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.target}</p>
) : null}
</div>
{channel !== "EMAIL" ? (
<div>
<label className="field-label" htmlFor="notify-secret">
Geheimnis {isEdit ? "(leer = unveraendert)" : "(optional)"}
</label>
<input
id="notify-secret"
className="field font-mono text-xs"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
placeholder={isEdit ? "••••••••" : ""}
/>
<p className="mt-1 text-[11px] text-slate-500">
{channel === "NTFY"
? "Wird als Bearer-Token gesendet – fuer geschuetzte Themen."
: "Signiert den Rumpf per HMAC-SHA256 im Kopf X-Nebula-Signature."}
</p>
</div>
) : null}
<div>
<span className="field-label">Anlaesse *</span>
<div className="space-y-1.5">
{NOTIFICATION_EVENTS.map((event) => {
const option = EVENT_META[event];
const checked = events.includes(event);
return (
<label
key={event}
className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-white/8 bg-black/20 px-3 py-2"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setEvents((current) =>
e.target.checked
? [...current, event]
: current.filter((value) => value !== event),
)
}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">
{option.emoji} {option.label}
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
{option.description}
</span>
</span>
</label>
);
})}
</div>
{fieldErrors.events ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.events}</p>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || events.length === 0}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
);
}
+126
View File
@@ -9,6 +9,7 @@ import {
Download,
Fingerprint,
FolderGit2,
Hourglass,
Loader2,
Lock,
Plus,
@@ -67,6 +68,12 @@ export interface AdminData {
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
eligible: number;
};
stale: {
afterDays: number;
lastCheckAt: string | Date | null;
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
listeners: number;
};
}
type Notice = { ok: boolean; text: string } | null;
@@ -187,6 +194,7 @@ export function AdminManager({ data }: { data: AdminData }) {
</section>
<AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} />
{/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5">
@@ -574,6 +582,124 @@ function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
);
}
const STALE_OPTIONS = [14, 30, 60, 90, 180, 365];
function StaleSection({ data }: { data: AdminData["stale"] }) {
const router = useRouter();
const [days, setDays] = useState(data.afterDays);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(value: number) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staleAfterDays: value }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setNotice({ ok: true, text: "Gespeichert." });
router.refresh();
} catch (caught) {
setDays(data.afterDays);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-amber-500/12 text-amber-300">
<Hourglass className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Stillstand
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Ab wann ein Projekt als eingeschlafen gilt. Massstab ist der letzte Commit im
verknuepften Repository – das Aenderungsdatum taugt nicht, weil jeder Abgleich
es anfasst. Projekte ohne Repository bleiben aussen vor, ebenso fertige und
archivierte.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="stale-days">
Schwelle
</label>
<select
id="stale-days"
className="field"
value={days}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setDays(value);
void save(value);
}}
>
{STALE_OPTIONS.map((value) => (
<option key={value} value={value}>
nach {value} Tagen ohne Commit
</option>
))}
</select>
</div>
<dl className="grid grid-cols-2 gap-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Letzte Pruefung
</dt>
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
{data.lastCheckAt ? formatRelative(data.lastCheckAt) : "noch keine"}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Empfaenger
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.listeners}</dd>
</div>
</dl>
</div>
{data.listeners === 0 ? (
<p className="mt-3 text-[11px] text-slate-600">
Niemand hoert derzeit auf diesen Anlass – die Pruefung laeuft dann gar nicht
erst. Einzurichten unter „Mein Konto → Benachrichtigungen“.
</p>
) : (
<p className="mt-3 text-[11px] text-slate-600">
Die Pruefung laeuft hoechstens einmal am Tag. Je Projekt wird einmal gemeldet;
erneut erst, wenn danach wieder ein Commit kam und es wieder still wurde.
</p>
)}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
function ModeCard({
active,
disabled,
+213
View File
@@ -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);
}
+159
View File
@@ -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(),
};
}
+126
View File
@@ -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");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { daysSince, isStaleDue } from "./stale";
/**
* Die Entscheidung, wann ein Projekt als eingeschlafen gilt – und vor allem,
* wann es *nicht* erneut gemeldet wird.
*/
const TAG = 86_400_000;
const jetzt = new Date("2026-07-26T12:00:00Z");
const vorTagen = (tage: number) => new Date(jetzt.getTime() - tage * TAG);
/** Schwelle: 60 Tage. */
const cutoff = vorTagen(60);
describe("isStaleDue", () => {
it("meldet ein Projekt, dessen letzter Commit laenger zurueckliegt", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nichts innerhalb der Frist", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(30), cutoff)).toBe(false);
});
it("meldet nichts genau an der Schwelle", () => {
expect(isStaleDue({ notifiedStaleAt: null }, cutoff, cutoff)).toBe(false);
});
it("meldet nichts ohne bekannten letzten Commit", () => {
// Ein Projekt ohne Repository oder ohne Snapshot laesst sich nicht
// beurteilen – lieber schweigen als raten.
expect(isStaleDue({ notifiedStaleAt: null }, null, cutoff)).toBe(false);
});
it("meldet dasselbe Projekt kein zweites Mal", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(5) }, vorTagen(90), cutoff)).toBe(false);
});
it("meldet erneut, wenn zwischendurch gearbeitet wurde und es wieder still ist", () => {
// Gemeldet vor 200 Tagen, danach ein Commit vor 90 Tagen, seitdem Ruhe.
expect(isStaleDue({ notifiedStaleAt: vorTagen(200) }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nicht erneut, wenn der Commit vor der letzten Meldung lag", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(10) }, vorTagen(90), cutoff)).toBe(false);
});
});
describe("daysSince", () => {
it("zaehlt volle Tage", () => {
expect(daysSince(vorTagen(60), jetzt)).toBe(60);
expect(daysSince(vorTagen(1), jetzt)).toBe(1);
});
it("rundet angebrochene Tage ab", () => {
expect(daysSince(new Date(jetzt.getTime() - 1.9 * TAG), jetzt)).toBe(1);
});
it("liefert null fuer soeben", () => {
expect(daysSince(jetzt, jetzt)).toBe(0);
});
});
+128
View File
@@ -0,0 +1,128 @@
import type { Project, RepoSnapshot } from "@prisma/client";
import { APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { statusOf } from "@/lib/status";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
/**
* Meldet Projekte, in denen laenger nichts passiert ist.
*
* Anders als die uebrigen Anlaesse haengt dieser an keinem Ereignis – es
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
* einmal am Tag.
*
* Massstab ist ausschliesslich der letzte Commit im verknuepften
* Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
* Projekte ohne Repository bleiben folglich aussen vor.
*/
export interface StaleReport {
at: string;
checked: number;
stale: number;
notified: number;
thresholdDays: number;
}
/** Entscheidet, ob ein Projekt jetzt zu melden ist. */
export function isStaleDue(
project: Pick<Project, "notifiedStaleAt">,
lastCommitAt: Date | null,
cutoff: Date,
): boolean {
// Ohne bekannten letzten Commit gibt es nichts zu beurteilen.
if (!lastCommitAt) return false;
// Noch innerhalb der Frist.
if (lastCommitAt >= cutoff) return false;
// Noch nie gemeldet: jetzt.
if (!project.notifiedStaleAt) return true;
// Schon gemeldet – erneut nur, wenn danach wieder ein Commit kam und es
// seitdem wieder still geworden ist.
return lastCommitAt > project.notifiedStaleAt;
}
/** Wie viele volle Tage seit dem letzten Commit vergangen sind. */
export function daysSince(date: Date, now: Date = new Date()): number {
return Math.floor((now.getTime() - date.getTime()) / 86_400_000);
}
function message(
project: Project,
snapshot: RepoSnapshot | null,
tage: number,
): NotificationMessage {
return {
event: "PROJECT_STALE",
title: `${EVENT_META.PROJECT_STALE.emoji} ${project.name}: seit ${tage} Tagen kein Commit`,
body:
`Der letzte Commit liegt ${tage} Tage zurueck. ` +
`Status: ${statusOf(project.status).label}. Vielleicht abschliessen oder archivieren?`,
priority: "normal",
url: snapshot?.htmlUrl ?? `${APP_URL}/projects/${project.id}`,
project: { id: project.id, name: project.name, slug: project.slug },
at: new Date().toISOString(),
};
}
/**
* Ein Durchlauf. Meldet je Projekt hoechstens einmal und schreibt den
* Merkstand fort – auch dann, wenn niemand zuhoert.
*/
export async function runStaleCheck(thresholdDays: number): Promise<StaleReport> {
const now = new Date();
const cutoff = new Date(now.getTime() - thresholdDays * 86_400_000);
const kandidaten = await prisma.project.findMany({
where: {
// Fertige und archivierte Projekte duerfen ruhen.
status: { notIn: ["DONE", "ARCHIVED"] },
ownerId: { not: null },
snapshot: { isNot: null },
},
include: { snapshot: true },
});
const faellig = kandidaten.filter((project) =>
isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff),
);
let zugestellt = 0;
for (const project of faellig) {
const lastCommitAt = project.snapshot?.lastCommitAt;
if (!lastCommitAt || !project.ownerId) continue;
const ziele = await prisma.notificationTarget.findMany({
where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } },
});
for (const ziel of ziele) {
const ergebnis = await deliver(
ziel,
message(project, project.snapshot, daysSince(lastCommitAt, now)),
);
zugestellt += 1;
await prisma.notificationTarget.update({
where: { id: ziel.id },
data: { lastSentAt: now, lastOk: ergebnis.ok, lastMessage: ergebnis.message },
});
}
// Auch ohne Ziel vermerken, sonst kaeme beim ersten eingerichteten Ziel
// die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
data: { notifiedStaleAt: now },
});
}
return {
at: now.toISOString(),
checked: kandidaten.length,
stale: faellig.length,
notified: zugestellt,
thresholdDays,
};
}
+72
View File
@@ -0,0 +1,72 @@
import type { NotificationEvent } from "@prisma/client";
/** Reihenfolge fuer die Oberflaeche. */
export const NOTIFICATION_EVENTS: NotificationEvent[] = [
"SYNC_FAILED",
"CI_FAILED",
"NEW_RELEASE",
"PROJECT_STALE",
];
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: "🏷️",
},
PROJECT_STALE: {
label: "Projekt steht still",
description: "Seit laengerem kein Commit mehr. Die Schwelle setzt ein Administrator.",
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;
+4
View File
@@ -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 };
}
}
+44 -5
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { runStaleCheck } from "@/lib/notify/stale";
import { syncProject } from "@/lib/sync";
/**
@@ -20,6 +21,8 @@ import { syncProject } from "@/lib/sync";
*/
const TICK_MS = 60_000;
/** Stillstand aendert sich langsam – einmal am Tag genuegt. */
const STALE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Kleine Verschnaufpause zwischen zwei Projekten. */
const PAUSE_MS = 400;
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
@@ -64,18 +67,54 @@ export function startAutoSyncScheduler() {
async function tick() {
try {
const settings = await getAppSettings();
if (!settings.autoSyncEnabled) return;
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
if (Date.now() - last < intervalMs) return;
if (settings.autoSyncEnabled) {
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
if (Date.now() - last >= intervalMs) {
await runAutoSyncPass("zeitplan");
}
}
await runAutoSyncPass("zeitplan");
// Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem
// gespeicherten Stand, dafuer muss nichts geholt werden.
await maybeCheckStale();
} catch (error) {
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
}
}
/**
* Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt
* jemand darauf hoert – sonst waere es reine Datenbanklast.
*/
async function maybeCheckStale() {
const settings = await getAppSettings();
const last = settings.lastStaleCheckAt?.getTime() ?? 0;
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
const zuhoerer = await prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
});
// Zeitstempel auch ohne Zuhoerer setzen, damit nicht jede Minute gezaehlt
// wird.
await prisma.appSettings.update({
where: { id: "singleton" },
data: { lastStaleCheckAt: new Date() },
});
if (zuhoerer === 0) return;
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
if (report.stale > 0) {
console.log(
`[nebula] Stillstand: ${report.stale} von ${report.checked} Projekten ` +
`ueber ${report.thresholdDays} Tage ohne Commit, ${report.notified} Meldungen.`,
);
}
}
/**
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { notificationTargetCreateSchema } from "./validation";
/**
* Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen
* Anlasses war das Zod-Schema nicht mitgezogen worden, die API lehnte ihn
* mit 422 ab. Die Liste steht an zwei Stellen – hier wird geprueft, dass
* sie zusammenpassen.
*/
const basis = {
name: "Handy",
channel: "NTFY" as const,
target: "https://ntfy.sh/mein-thema",
};
describe("notificationTargetCreateSchema", () => {
it("kennt jeden Anlass, den die Oberflaeche anbietet", () => {
for (const event of NOTIFICATION_EVENTS) {
const ergebnis = notificationTargetCreateSchema.safeParse({
...basis,
events: [event],
});
expect(ergebnis.success, `Anlass ${event} wird abgelehnt`).toBe(true);
}
});
it("verlangt mindestens einen Anlass", () => {
expect(notificationTargetCreateSchema.safeParse({ ...basis, events: [] }).success).toBe(
false,
);
});
it("weist unbekannte Anlaesse ab", () => {
expect(
notificationTargetCreateSchema.safeParse({ ...basis, events: ["WAS_AUCH_IMMER"] })
.success,
).toBe(false);
});
it("verlangt bei E-Mail eine Adresse, keine URL", () => {
const alsUrl = notificationTargetCreateSchema.safeParse({
name: "Post",
channel: "EMAIL",
target: "https://example.com",
events: ["SYNC_FAILED"],
});
expect(alsUrl.success).toBe(false);
const alsAdresse = notificationTargetCreateSchema.safeParse({
name: "Post",
channel: "EMAIL",
target: "ich@example.com",
events: ["SYNC_FAILED"],
});
expect(alsAdresse.success).toBe(true);
});
it("verlangt bei Webhook und ntfy eine http-URL", () => {
for (const channel of ["WEBHOOK", "NTFY"] as const) {
expect(
notificationTargetCreateSchema.safeParse({
name: "X",
channel,
target: "ich@example.com",
events: ["SYNC_FAILED"],
}).success,
).toBe(false);
}
});
});
+36
View File
@@ -146,6 +146,8 @@ export const profileUpdateSchema = z.object({
export const adminSettingsSchema = z.object({
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
allowRegistration: z.boolean().optional(),
/** Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. */
staleAfterDays: z.coerce.number().int().min(7).max(365).optional(),
autoSyncEnabled: z.boolean().optional(),
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
@@ -170,6 +172,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", "PROJECT_STALE"] 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()),
});