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
|
### 6.4 Tests
|
||||||
|
|
||||||
`npm test` führt die Unit-Tests aus — reine Logik, kein DOM, keine Datenbank, unter einer
|
Zwei Ebenen. `npm run test:all` führt beide aus.
|
||||||
Sekunde:
|
|
||||||
|
**`npm test` — Unit-Tests** (Vitest, 56 Stück, unter einer Sekunde, ohne DOM und ohne
|
||||||
|
Datenbank):
|
||||||
|
|
||||||
| Bereich | Was geprüft wird |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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
|
**`npm run test:e2e` — Ende-zu-Ende** (Playwright, 13 Stück, rund 20 Sekunden). Läuft
|
||||||
CI-Pipeline. Das steht in der [ROADMAP](ROADMAP.md).
|
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.
|
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
|
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 test` | Unit-Tests (Vitest), unter einer Sekunde |
|
||||||
| `npm run test:watch` | Tests im Beobachtungsmodus |
|
| `npm run test:watch` | Tests im Beobachtungsmodus |
|
||||||
| `npm run typecheck` | TypeScript prüfen |
|
| `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:db` | Nur die Datenbank starten |
|
||||||
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
|
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
|
||||||
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
|
| `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 · Als Nächstes empfohlen
|
||||||
|
|
||||||
### 1.1 Tests ausbauen: Datenbank und Browser `L`
|
### 1.1 Benachrichtigungen `M`
|
||||||
|
|
||||||
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
|
Bei Sync-Fehlern, roter CI, neuen Releases oder Projekten, die lange
|
||||||
stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
|
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.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.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.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
|
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
|
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||||
|
|||||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@playwright/test": "^1.62.0",
|
"@playwright/test": "^1.62.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
@@ -2319,6 +2321,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -4390,6 +4402,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.8",
|
"version": "0.6.8",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"@playwright/test": "^1.62.0",
|
"@playwright/test": "^1.62.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"prisma": "^6.8.2",
|
"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
|
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.
|
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
TODO
|
TODO
|
||||||
@@ -78,6 +95,7 @@ model User {
|
|||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
connections GitConnection[]
|
connections GitConnection[]
|
||||||
|
notificationTargets NotificationTarget[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -249,6 +267,13 @@ model Project {
|
|||||||
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
|
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
|
||||||
progressFromTasks Boolean @default(false)
|
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[]
|
notes Note[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
snapshot RepoSnapshot?
|
snapshot RepoSnapshot?
|
||||||
@@ -263,6 +288,36 @@ model Project {
|
|||||||
@@index([ownerId])
|
@@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.
|
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
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"
|
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-haspopup="menu"
|
||||||
aria-expanded={open}
|
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">
|
<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}
|
{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 type { Project } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||||
|
import { notifyAfterSync } from "@/lib/notify";
|
||||||
import type { RepoSnapshotData } from "@/lib/git/types";
|
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
@@ -54,6 +55,8 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
|||||||
try {
|
try {
|
||||||
const data = await client.fetchRepo(ref);
|
const data = await client.fetchRepo(ref);
|
||||||
await persistSnapshot(project.id, data);
|
await persistSnapshot(project.id, data);
|
||||||
|
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||||
|
await notifyAfterSync(project.id);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
|
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 },
|
syncFailureCount: { increment: 1 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await notifyAfterSync(project.id);
|
||||||
return { ok: false, message };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,40 @@ export const adminUserUpdateSchema = z.object({
|
|||||||
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
|
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({
|
export const passkeyNameSchema = z.object({
|
||||||
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
|
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user