Compare commits
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Nebula – Beispielkonfiguration
|
||||||
|
# Kopieren nach ".env" und Werte anpassen.
|
||||||
|
# (Die .env wird sowohl von Next.js als auch von docker compose gelesen.)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Anwendung ────────────────────────────────────────────────
|
||||||
|
# Anzeigename: Seitentitel, Kopfzeile und Name im Passkey-Dialog.
|
||||||
|
APP_NAME="Nebula"
|
||||||
|
# Kleine Zeile unter dem Namen in der Kopfzeile.
|
||||||
|
APP_TAGLINE="Vibe Coding"
|
||||||
|
|
||||||
|
# Oeffentliche Adresse der Instanz – genau so, wie du sie im Browser aufrufst.
|
||||||
|
#
|
||||||
|
# WICHTIG fuer Passkeys: Daraus werden die WebAuthn-Relying-Party-ID (der
|
||||||
|
# Hostname) und der erwartete Origin abgeleitet. Passt der Wert nicht zur
|
||||||
|
# aufgerufenen Adresse, lehnt der Browser die Anmeldung ab. Passkeys brauchen
|
||||||
|
# ausserdem einen sicheren Kontext: https:// – oder http://localhost.
|
||||||
|
#
|
||||||
|
# Wird die rpID spaeter geaendert (anderer Hostname), sind bestehende Passkeys
|
||||||
|
# ungueltig und muessen neu angelegt werden. Passwoerter bleiben davon unberuehrt.
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Port innerhalb des Containers bzw. im Standalone-Betrieb
|
||||||
|
PORT="3000"
|
||||||
|
# Host-Port-Mapping fuer den App-Container (--profile full). Nur auf dem
|
||||||
|
# Loopback – nach draussen geht es ueber einen Reverse Proxy. Soll die
|
||||||
|
# Instanz im LAN erreichbar sein, hier "3000" eintragen.
|
||||||
|
APP_PORT="127.0.0.1:3000"
|
||||||
|
|
||||||
|
# ── Datenbank ────────────────────────────────────────────────
|
||||||
|
# Standalone-Betrieb: App laeuft lokal, Postgres via "npm run docker:db"
|
||||||
|
# oder als nativ installierter Dienst.
|
||||||
|
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
|
||||||
|
|
||||||
|
# Im Docker-Vollbetrieb (--profile full) setzt compose DATABASE_URL selbst
|
||||||
|
# auf den Service-Hostnamen "db" – der Wert oben wird dann nicht verwendet.
|
||||||
|
|
||||||
|
# Zugangsdaten des Postgres-Containers aus docker-compose.yml
|
||||||
|
POSTGRES_USER="nebula"
|
||||||
|
POSTGRES_PASSWORD="nebula"
|
||||||
|
POSTGRES_DB="nebula"
|
||||||
|
# Host-Port der Datenbank. Wirkt NUR zusammen mit docker-compose.dev.yml
|
||||||
|
# (npm run docker:db) – die Basisdatei veroeffentlicht keinen DB-Port, weil
|
||||||
|
# die Anwendung im Container "db:5432" anspricht und nichts davon nach
|
||||||
|
# aussen muss. Auf einem Server bleibt dieser Wert also folgenlos.
|
||||||
|
POSTGRES_PORT="127.0.0.1:5432"
|
||||||
|
# Host-Port fuer Adminer (--profile tools)
|
||||||
|
ADMINER_PORT="127.0.0.1:8080"
|
||||||
|
|
||||||
|
# ── Betrieb ohne veroeffentlichte Ports ──────────────────────
|
||||||
|
# Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
|
||||||
|
# Reverse Proxy den Zugang, veroeffentlicht Nebula gar keinen Port. Dann diese
|
||||||
|
# beiden Zeilen aktivieren – danach genuegt ueberall "docker compose up -d":
|
||||||
|
# COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
|
||||||
|
# COMPOSE_PROFILES="full"
|
||||||
|
#
|
||||||
|
# Name des geteilten Netzes und der Alias, unter dem der Proxy Nebula findet.
|
||||||
|
# PROXY_NETWORK="proxy"
|
||||||
|
# PROXY_ALIAS="nebula"
|
||||||
|
|
||||||
|
# ── Sicherheit ───────────────────────────────────────────────
|
||||||
|
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
|
||||||
|
# Erzeugen mit: openssl rand -base64 32
|
||||||
|
# oder: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# oder: npm run setup (legt die .env inkl. Schluessel an)
|
||||||
|
# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Git-Tokens
|
||||||
|
# nicht mehr entschluesselbar und muessen neu hinterlegt werden. Anmeldungen
|
||||||
|
# und Passkeys sind davon nicht betroffen.
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Schutz vor Server Side Request Forgery: die Basis-URL einer Git-Verbindung
|
||||||
|
# ist eine Benutzereingabe und wird vom Server abgerufen.
|
||||||
|
#
|
||||||
|
# Voreinstellung: Loopback, Link-Local (169.254.x, Cloud-Metadaten), Multicast
|
||||||
|
# und CGNAT sind gesperrt; das private LAN ist erlaubt, weil dort ueblicherweise
|
||||||
|
# die eigene Gitea-Instanz steht.
|
||||||
|
|
||||||
|
# Gitea laeuft auf demselben Rechner wie Nebula? Dann auf true setzen.
|
||||||
|
# GIT_ALLOW_LOOPBACK="false"
|
||||||
|
|
||||||
|
# Kein Zugriff ins private Netz (10/8, 172.16/12, 192.168/16, fc00::/7)?
|
||||||
|
# GIT_BLOCK_PRIVATE="false"
|
||||||
|
|
||||||
|
# Strengster Modus: nur diese Hosts sind erlaubt, alles andere wird geblockt.
|
||||||
|
# Eine Angabe hier hebt die beiden Schalter darueber auf.
|
||||||
|
# GIT_ALLOWED_HOSTS="github.com,gitlab.com,git.meine-firma.de"
|
||||||
@@ -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
|
||||||
@@ -43,6 +43,15 @@ yarn-error.log*
|
|||||||
/data
|
/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
|
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
||||||
|
/backups
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/playwright/.cache
|
||||||
|
|
||||||
# editors
|
# editors
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Nebula – Hinweise für Claude Code
|
||||||
|
|
||||||
|
Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
|
||||||
|
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
|
||||||
|
|
||||||
|
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
|
||||||
|
Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||||
|
|
||||||
|
## Womit man arbeitet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docker:db # Postgres, muss laufen (mit dem dev-Overlay,
|
||||||
|
# sonst hat die DB keinen Host-Port)
|
||||||
|
npm run dev # Entwicklung
|
||||||
|
npm test # Unit-Tests (Vitest, ohne DB)
|
||||||
|
npx tsc --noEmit # Typprüfung
|
||||||
|
npm run build # Produktionsbau
|
||||||
|
docker compose --profile full up -d --build # alles im Container
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vor dem `npm run build` einen laufenden `npm start`/`npm run dev` beenden.**
|
||||||
|
Sonst hält der Prozess die Prisma-Engine-DLL und der Bau bricht unter Windows
|
||||||
|
mit `EPERM … query_engine-windows.dll.node` ab.
|
||||||
|
|
||||||
|
## Nicht verhandelbar
|
||||||
|
|
||||||
|
**Datentrennung.** Jede Abfrage filtert auf den Besitzer, und zwar an erster
|
||||||
|
Stelle in der `where`-Klausel. Schreiben und Löschen laufen über
|
||||||
|
`updateMany`/`deleteMany` mit `ownerId`, damit fremde Datensätze gar nicht
|
||||||
|
erst getroffen werden. Fremde IDs liefern **404, nicht 403** — die Antwort
|
||||||
|
soll nicht bestätigen, dass es den Datensatz gibt.
|
||||||
|
|
||||||
|
**Jeder Endpunkt prüft selbst.** `requireUserApi()` bzw. `requireUserPage()`
|
||||||
|
sind die Sicherheitsgrenze. Die Middleware schaut nur, ob überhaupt ein
|
||||||
|
Cookie mitkommt — im Edge-Runtime gibt es keine Datenbank.
|
||||||
|
|
||||||
|
**Eingaben validieren.** Zod an jeder API-Grenze, Body über `readJsonBody`
|
||||||
|
(erzwingt `application/json` und begrenzt die Größe).
|
||||||
|
|
||||||
|
**Keine Geheimnisse nach außen.** Git-Tokens verlassen den Server nie;
|
||||||
|
Verbindungen werden über `connectionSafeSelect` geladen. Projekt-Exporte
|
||||||
|
enthalten grundsätzlich keine Tokens.
|
||||||
|
|
||||||
|
**Ausgehende Aufrufe nur über `lib/git/http.ts`** — dort greift der
|
||||||
|
SSRF-Schutz (`lib/security/ssrf.ts`).
|
||||||
|
|
||||||
|
## Fallstricke, die schon zugeschlagen haben
|
||||||
|
|
||||||
|
- **`node:`-Module in Client-Komponenten.** `lib/crypto`, `lib/auth/password`,
|
||||||
|
`lib/git/index` ziehen `node:crypto`/`node:net` nach. Client-Komponenten
|
||||||
|
importieren stattdessen `lib/git/meta` bzw. `lib/auth/constants`.
|
||||||
|
- **`instrumentation.ts` wird auch für Edge übersetzt.** Der dynamische Import
|
||||||
|
muss *innerhalb* von `if (process.env.NEXT_RUNTIME === "nodejs")` stehen,
|
||||||
|
sonst scheitert der Bau an `node:crypto`.
|
||||||
|
- **Eigene CSS-Klassen gehören in `app/components.css`** und dort in
|
||||||
|
`@layer components`. Außerhalb eines Layers gewinnen sie gegen
|
||||||
|
Tailwind-Utilities — dann greift `pl-9` neben `.field` nicht mehr.
|
||||||
|
- **Menüs und Dialoge über Portal.** Karten setzen `overflow-hidden` und
|
||||||
|
`backdrop-filter`; Letzteres erzeugt einen neuen Bezugsrahmen, in dem
|
||||||
|
`position: fixed` und `z-index` nicht mehr wie erwartet wirken. Dafür gibt
|
||||||
|
es `components/ui/Popover.tsx` und `components/ui/Modal.tsx`.
|
||||||
|
- **Overlays brauchen einen deckenden Hintergrund** (`.popover`), nicht
|
||||||
|
`.glass` — dessen 3,5 % Deckkraft lassen alles durchscheinen.
|
||||||
|
- **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst
|
||||||
|
weichen Server-Render und Hydration voneinander ab. Relative Zeiten
|
||||||
|
bekommen `suppressHydrationWarning`.
|
||||||
|
|
||||||
|
## Migrationen
|
||||||
|
|
||||||
|
`prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow"
|
||||||
|
npx prisma migrate diff --from-migrations prisma/migrations \
|
||||||
|
--to-schema-datamodel prisma/schema.prisma \
|
||||||
|
--shadow-database-url postgresql://nebula:nebula@localhost:5432/nebula_shadow \
|
||||||
|
--script --output prisma/migrations/<stempel>_<name>/migration.sql
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--from-migrations`, nicht `--from-schema-datasource`.** Letzteres
|
||||||
|
vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
|
||||||
|
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
|
||||||
|
einmal.
|
||||||
|
|
||||||
|
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex
|
||||||
|
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird
|
||||||
|
bislang in Ruhe gelassen, aber das ist nicht garantiert).
|
||||||
|
|
||||||
|
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
|
||||||
|
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
|
||||||
|
Vorgehen wie in `20260726230000_repositories`: neue Tabelle anlegen, Bestand
|
||||||
|
per `INSERT … SELECT` übernehmen, dann erst die alten Spalten entfernen.
|
||||||
|
Danach `migrate diff` erneut laufen lassen — kommt „empty migration“ heraus,
|
||||||
|
stimmt das Ergebnis mit dem Schema überein. Und gegen eine Kopie mit echten
|
||||||
|
Daten prüfen, nicht nur gegen eine leere Datenbank.
|
||||||
|
|
||||||
|
## Prüfen, nicht behaupten
|
||||||
|
|
||||||
|
Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`.
|
||||||
|
|
||||||
|
Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
|
||||||
|
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
|
||||||
|
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
|
||||||
|
|
||||||
|
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
|
||||||
|
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
|
||||||
|
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
|
||||||
|
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
|
||||||
|
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
|
||||||
|
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
|
||||||
|
|
||||||
|
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
|
||||||
|
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
|
||||||
|
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
|
||||||
|
sich nativ ab. Das sieht aus wie ein Anwendungsfehler, ist aber keiner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkill` gibt es in dieser Git-Bash nicht. Prozesse über PowerShell beenden
|
||||||
|
(`Get-CimInstance Win32_Process … | Stop-Process`) — sonst läuft der alte
|
||||||
|
Server weiter, hält die Prisma-DLL und blockiert den nächsten Bau.
|
||||||
|
|
||||||
|
**Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen
|
||||||
|
und danach löschen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||||
|
DATABASE_URL=postgresql://nebula:nebula@localhost:5432/nebula_test npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
Code-Kommentare, Commit-Nachrichten und Oberfläche auf Deutsch, **ohne
|
||||||
|
Umlaute in Bezeichnern und Commit-Nachrichten** (bestehende Konvention).
|
||||||
|
In Markdown-Dateien und in der Oberfläche sind Umlaute erwünscht.
|
||||||
|
|
||||||
|
Commits: Conventional Commits, Betreff deutsch, Rumpf erklärt **warum** —
|
||||||
|
nicht was der Diff ohnehin zeigt.
|
||||||
+412
@@ -0,0 +1,412 @@
|
|||||||
|
# Nebula auf einem Server betreiben
|
||||||
|
|
||||||
|
Schritt für Schritt: von der leeren Maschine bis zur laufenden Instanz hinter
|
||||||
|
TLS — und wie Updates danach ablaufen.
|
||||||
|
|
||||||
|
Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
|
||||||
|
[README.md](README.md). Hier geht es um den Dauerbetrieb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was du brauchst
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
|
||||||
|
| Sonst | `git`, `curl`, eine POSIX-Shell — **kein Node, kein npm** |
|
||||||
|
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
|
||||||
|
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
|
||||||
|
| Domain | Eine (Unter-)Domain, die auf den Server zeigt |
|
||||||
|
| TLS | Pflicht, wenn du Passkeys willst — siehe Schritt 6 |
|
||||||
|
|
||||||
|
> **Warum TLS Pflicht ist:** Passkeys verlangen einen sicheren Kontext.
|
||||||
|
> Über `http://` an einer LAN-IP funktionieren sie nicht. Passwort und
|
||||||
|
> Zwei-Faktor gehen auch ohne, aber dann läuft dein Passwort im Klartext
|
||||||
|
> übers Netz — betreibe Nebula nicht ohne TLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Docker installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
sudo usermod -aG docker "$USER"
|
||||||
|
newgrp docker # oder ab- und wieder anmelden
|
||||||
|
|
||||||
|
docker --version && docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2 · Nebula holen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/nebula && sudo chown "$USER" /opt/nebula
|
||||||
|
git clone https://git.example.com/dein-name/nebula.git /opt/nebula
|
||||||
|
cd /opt/nebula
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3 · Konfigurieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup.sh # legt .env an, würfelt ein APP_SECRET, setzt Rechte 600
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript ist reines POSIX-Shell und braucht **kein Node** — auf einem
|
||||||
|
reinen Docker-Host ist keins installiert. Es nimmt `openssl`, falls
|
||||||
|
vorhanden, sonst `/dev/urandom`.
|
||||||
|
|
||||||
|
Ganz von Hand geht auch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
openssl rand -base64 32 # den Wert als APP_SECRET eintragen
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann die `.env` öffnen und mindestens diese drei Werte anpassen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Die Adresse, unter der du Nebula im Browser aufrufst.
|
||||||
|
# Bestimmt die WebAuthn-rpID — passt sie nicht, scheitern Passkeys.
|
||||||
|
APP_URL="https://nebula.deine-domain.de"
|
||||||
|
|
||||||
|
# Verschlüsselt die Git-Tokens. Lang, zufällig, niemals teilen.
|
||||||
|
APP_SECRET="…dein-langer-zufallswert…"
|
||||||
|
|
||||||
|
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
|
||||||
|
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ports müssen nicht angefasst werden: `APP_PORT` steht schon auf
|
||||||
|
`127.0.0.1:3000`, und die Datenbank veröffentlicht gar nichts.
|
||||||
|
|
||||||
|
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
|
||||||
|
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
|
||||||
|
> bleiben unberührt.
|
||||||
|
>
|
||||||
|
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
|
||||||
|
> sie an die rpID gebunden sind. Passwörter funktionieren weiter.
|
||||||
|
|
||||||
|
### Welche Ports überhaupt hinausgehen
|
||||||
|
|
||||||
|
Nach einer regulären Installation ist es **genau einer**:
|
||||||
|
|
||||||
|
| Container | Host-Port | Wann |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `nebula-app` | `APP_PORT`, Vorgabe `127.0.0.1:3000` | `--profile full` |
|
||||||
|
| `nebula-db` | — | **nie** |
|
||||||
|
| `nebula-adminer` | `ADMINER_PORT`, Vorgabe `127.0.0.1:8080` | nur `--profile tools` |
|
||||||
|
|
||||||
|
**Die Datenbank veröffentlicht nichts.** Im Vollbetrieb spricht die Anwendung
|
||||||
|
Postgres über das interne Docker-Netz als `db:5432` an — die `DATABASE_URL`
|
||||||
|
setzt Compose selbst. Auch `scripts/backup.sh` und `restore.sh` gehen über
|
||||||
|
`docker exec`, nicht über den Host. Ein Host-Port wäre nur eine zusätzliche
|
||||||
|
Angriffsfläche und würde mit anderen Postgres-Containern auf demselben Host
|
||||||
|
um den Port streiten.
|
||||||
|
|
||||||
|
Wer die Datenbank beim Entwickeln doch vom Host aus braucht — `npm run dev`
|
||||||
|
läuft ja außerhalb des Containers — nimmt `docker-compose.dev.yml` dazu
|
||||||
|
(`npm run docker:db`). Auf einem Server hat diese Datei nichts zu suchen.
|
||||||
|
|
||||||
|
Auch der Port der Anwendung liegt auf `127.0.0.1`: nach draußen geht es über
|
||||||
|
den Reverse Proxy aus Schritt 6.
|
||||||
|
|
||||||
|
### Variante: gar keinen Port veröffentlichen
|
||||||
|
|
||||||
|
Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
|
||||||
|
Reverse Proxy den Zugang, braucht Nebula überhaupt keinen Host-Port. Dafür
|
||||||
|
gibt es **`docker-compose.server.yml`**: es nimmt die letzte verbliebene
|
||||||
|
Freigabe zurück und hängt die Anwendung in ein geteiltes Netz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create proxy # falls der Proxy-Stack es nicht schon anlegt
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env
|
||||||
|
COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
|
||||||
|
COMPOSE_PROFILES="full"
|
||||||
|
PROXY_NETWORK="proxy" # so heißt das Netz
|
||||||
|
PROXY_ALIAS="nebula" # so findet der Proxy die Anwendung
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose liest beides aus der `.env`. Danach genügt überall — auch in
|
||||||
|
`scripts/update.sh` — ein schlichtes `docker compose up -d`. Im Proxy zeigt
|
||||||
|
das Ziel dann nicht mehr auf `127.0.0.1:3000`, sondern auf **`nebula:3000`**;
|
||||||
|
Schritt 6 zeigt beide Schreibweisen.
|
||||||
|
|
||||||
|
> Ohne die Datei würde ein Override die Portlisten **zusammenhängen** statt
|
||||||
|
> sie zu ersetzen — Compose führt `ports` anfügend zusammen. Das `!reset`
|
||||||
|
> darin ist genau dafür da und braucht Compose 2.24 oder neuer.
|
||||||
|
>
|
||||||
|
> Das Trennzeichen in `COMPOSE_FILE` ist unter Linux der Doppelpunkt, unter
|
||||||
|
> Windows das Semikolon.
|
||||||
|
|
||||||
|
## 4 · Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste Bau dauert ein paar Minuten. Der Container spielt beim Start
|
||||||
|
selbst die Migrationen ein und wartet dabei auf die Datenbank.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das Lesen
|
||||||
|
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne veröffentlichten Port geht die Frage an den Container statt an den Host
|
||||||
|
— das Abbild bringt einen `HEALTHCHECK` mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect --format '{{.State.Health.Status}}' nebula-app # healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5 · Ersten Zugang anlegen
|
||||||
|
|
||||||
|
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
|
||||||
|
vorübergehend über einen SSH-Tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 3000:127.0.0.1:3000 benutzer@server
|
||||||
|
# dann lokal http://localhost:3000/setup öffnen
|
||||||
|
```
|
||||||
|
|
||||||
|
Dort legst du das Administratorkonto an und wählst die Betriebsart.
|
||||||
|
**Danach ist `/setup` dauerhaft gesperrt.**
|
||||||
|
|
||||||
|
Anschließend gleich unter *Mein Konto*:
|
||||||
|
|
||||||
|
1. **Passkey hinzufügen** — geht erst, wenn TLS steht
|
||||||
|
2. **Zwei-Faktor einrichten** und die Wiederherstellungscodes wegschreiben
|
||||||
|
|
||||||
|
## 6 · Reverse Proxy mit TLS
|
||||||
|
|
||||||
|
Nebula bringt keinen eigenen TLS-Abschluss mit. Beispiel mit **Caddy**, das
|
||||||
|
Zertifikate von selbst besorgt und erneuert:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# /etc/caddy/Caddyfile
|
||||||
|
nebula.deine-domain.de {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
|
||||||
|
Let's Encrypt und leitet `http` auf `https` um.
|
||||||
|
|
||||||
|
Läuft der Proxy selbst als Container im geteilten Netz (die Variante ohne
|
||||||
|
veröffentlichte Ports), lautet das Ziel stattdessen `nebula:3000` —
|
||||||
|
Docker löst den Alias auf:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
nebula.deine-domain.de {
|
||||||
|
reverse_proxy nebula:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Dasselbe mit nginx</summary>
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name nebula.deine-domain.de;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/nebula.deine-domain.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/nebula.deine-domain.de/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zertifikat mit `certbot --nginx -d nebula.deine-domain.de`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> Steht `APP_URL` auf `https://…`, setzt Nebula das `Secure`-Flag auf die
|
||||||
|
> Sitzungscookies. Läuft der Proxy auf `https`, die `APP_URL` aber noch auf
|
||||||
|
> `http`, funktioniert die Anmeldung — nur ohne dieses Flag. Also beides
|
||||||
|
> gleichziehen.
|
||||||
|
|
||||||
|
## 7 · Firewall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 22/tcp
|
||||||
|
sudo ufw allow 80,443/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
Port 3000 bleibt **zu**, 5432 wird ohnehin nie veröffentlicht. Die Firewall
|
||||||
|
ist hier der zweite Riegel, nicht der erste.
|
||||||
|
|
||||||
|
> Docker schreibt seine Portfreigaben direkt in `iptables` und geht dabei an
|
||||||
|
> `ufw` **vorbei**. Eine Freigabe auf `0.0.0.0` steht also im Netz, auch wenn
|
||||||
|
> `ufw` sie zu melden scheint. Genau deshalb wird hier an `127.0.0.1`
|
||||||
|
> gebunden, statt sich auf die Firewall zu verlassen.
|
||||||
|
|
||||||
|
## 8 · Sicherungen automatisieren
|
||||||
|
|
||||||
|
`scripts/backup.sh` legt einen `pg_dump` unter `backups/` ab. Täglich per
|
||||||
|
Cron:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# Jede Nacht um 3:15 sichern, 30 Tage aufheben
|
||||||
|
15 3 * * * cd /opt/nebula && ./scripts/backup.sh >> /var/log/nebula-backup.log 2>&1
|
||||||
|
30 3 * * * find /opt/nebula/backups -name '*.sql.gz' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurückspielen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore.sh backups/nebula-2026-07-27-03-15-00.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
> Die Sicherungen liegen auf **demselben Server**. Das hilft gegen einen
|
||||||
|
> verpatzten Import, nicht gegen einen Plattenausfall. Zieh sie regelmäßig
|
||||||
|
> woandershin — `rsync`, `restic`, was du ohnehin nutzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
|
||||||
|
Reihenfolge und bricht ab, sobald ein Schritt scheitert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nebula
|
||||||
|
./scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Was es tut:
|
||||||
|
|
||||||
|
| Schritt | |
|
||||||
|
| --- | --- |
|
||||||
|
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
|
||||||
|
| | *(alles ohne Node — das Skript liest die Version selbst aus der `package.json`)* |
|
||||||
|
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
|
||||||
|
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
|
||||||
|
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
|
||||||
|
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
|
||||||
|
| **6 Prüfen** | Bis zu zwei Minuten warten, bis der Container sich gesund meldet |
|
||||||
|
|
||||||
|
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
|
||||||
|
nennt den Weg zurück.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update.sh --yes # ohne Rückfrage, für Cron
|
||||||
|
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung
|
||||||
|
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wenn doch etwas schiefgeht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zurück auf den vorigen Stand (die Kennung nennt das Skript beim Abbruch)
|
||||||
|
git checkout <kennung>
|
||||||
|
./scripts/update.sh --no-pull --yes
|
||||||
|
|
||||||
|
# Nur wenn eine Migration die Daten beschädigt hat:
|
||||||
|
./scripts/restore.sh backups/<die-sicherung-von-vorhin>.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Migrationen werden nicht zurückgerollt.** Eine Migration, die eine Spalte
|
||||||
|
> entfernt, ist nicht umkehrbar — deshalb sichert das Skript vorher. Steht
|
||||||
|
> ein Update mit Datenmigration an, lies vorher den Änderungsverlauf
|
||||||
|
> (Versionsnummer in der Fußzeile).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
### Zustand ansehen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full ps
|
||||||
|
docker compose --profile full logs -f app
|
||||||
|
curl -s localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neu starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Speicher- und Plattenverbrauch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stats --no-stream nebula-app nebula-db
|
||||||
|
docker system df
|
||||||
|
docker image prune -f # alte Abbilder nach mehreren Updates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatischer Abgleich
|
||||||
|
|
||||||
|
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
|
||||||
|
Automatischer Abgleich*: Abstand, Repositories je Durchlauf, ob fertige
|
||||||
|
Projekte ausgelassen werden. Der Zeitgeber läuft im Serverprozess selbst —
|
||||||
|
kein zusätzlicher Container, kein Cron.
|
||||||
|
|
||||||
|
Wer lieber von außen taktet, lässt ihn aus und ruft
|
||||||
|
`POST /api/admin/sync/run` auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Härtung, die schon eingebaut ist
|
||||||
|
|
||||||
|
Damit du weißt, worum du dich **nicht** kümmern musst:
|
||||||
|
|
||||||
|
- Der Container läuft als **unprivilegierter Benutzer** (`nextjs`, UID 1001),
|
||||||
|
nicht als root.
|
||||||
|
- **Content-Security-Policy** mit Nonce, `strict-dynamic` und ohne
|
||||||
|
`unsafe-inline` für Skripte.
|
||||||
|
- **CSRF-Prüfung** über `Sec-Fetch-Site`/`Origin` bei jedem schreibenden
|
||||||
|
Zugriff.
|
||||||
|
- **SSRF-Schutz** bei allen ausgehenden Aufrufen: jede Adresse wird
|
||||||
|
aufgelöst und gegen Loopback, Link-Local und Metadaten-Endpunkte geprüft,
|
||||||
|
Weiterleitungen eingeschlossen.
|
||||||
|
- **Ratenbegrenzung** auf Anmeldung, ausgehende Abrufe und schreibende
|
||||||
|
Zugriffe; Kontosperre nach acht Fehlversuchen.
|
||||||
|
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
|
||||||
|
verlassen den Server nie — auch nicht im Export.
|
||||||
|
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
|
||||||
|
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
|
||||||
|
|
||||||
|
### Was du beisteuern musst
|
||||||
|
|
||||||
|
- **TLS** über den Reverse Proxy
|
||||||
|
- **`APP_SECRET`** lang und zufällig
|
||||||
|
- **Datenbankport** nicht nach außen öffnen
|
||||||
|
- **Sicherungen** wegschaffen
|
||||||
|
- **Systemupdates**: `unattended-upgrades` für das Betriebssystem,
|
||||||
|
`./scripts/update.sh` für Nebula
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mehrbenutzerbetrieb
|
||||||
|
|
||||||
|
Nebula kann beides. Im Einzelbetrieb gibt es genau ein Konto und keine
|
||||||
|
Registrierung. Umschalten unter *Admin-Einstellungen*.
|
||||||
|
|
||||||
|
Wichtig zu wissen: **Projekte sind zwischen Konten hart getrennt.** Auch ein
|
||||||
|
Administrator sieht fremde Projekte nicht — er verwaltet Konten, keine
|
||||||
|
Inhalte. Wer gemeinsam an einem Projekt arbeiten will, ist mit Nebula falsch
|
||||||
|
beraten; das ist [bewusst nicht](ROADMAP.md) vorgesehen.
|
||||||
|
|
||||||
|
Die Selbstregistrierung ist standardmäßig aus. Lass sie aus, wenn die
|
||||||
|
Instanz aus dem Internet erreichbar ist und du nicht willst, dass sich
|
||||||
|
Fremde Konten anlegen.
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Nebula – Produktions-Image (Next.js standalone + Prisma)
|
||||||
|
# Build: docker compose --profile full build
|
||||||
|
# Start: docker compose --profile full up -d
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
# openssl wird von den Prisma-Engines benoetigt, libc6-compat von Next.js.
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Abhaengigkeiten ──────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
# prisma/ wird schon hier gebraucht, weil "postinstall" prisma generate ausfuehrt.
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
|
||||||
|
# ── Build ────────────────────────────────────────────────────
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Zur Build-Zeit wird keine echte Datenbank kontaktiert (alle Seiten sind
|
||||||
|
# dynamisch), Prisma verlangt die Variable aber beim Generieren.
|
||||||
|
ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build?schema=public"
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufzeit ─────────────────────────────────────────────────
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Prisma-CLI fuer "migrate deploy" beim Containerstart. Sie bekommt eine
|
||||||
|
# eigene, minimale Installation unter /opt: aus dem Builder laesst sie sich
|
||||||
|
# nicht herausloesen, weil @prisma/config einen ganzen Abhaengigkeitsbaum
|
||||||
|
# nachzieht (effect, c12, …). Die Version wird aus dem Builder uebernommen,
|
||||||
|
# damit CLI und generierter Client nicht auseinanderlaufen.
|
||||||
|
COPY --from=builder /app/node_modules/prisma/package.json /tmp/prisma-cli.json
|
||||||
|
RUN npm install --prefix /opt/prisma-cli --no-save --no-audit --no-fund \
|
||||||
|
"prisma@$(node -p "require('/tmp/prisma-cli.json').version")" \
|
||||||
|
&& rm -f /tmp/prisma-cli.json \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Query-Engine und Schema fuer die Laufzeit bzw. die Migration.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+773
@@ -0,0 +1,773 @@
|
|||||||
|
# Funktionsübersicht
|
||||||
|
|
||||||
|
Vollständige Auflistung dessen, was Nebula kann — gegliedert nach Bereichen.
|
||||||
|
Für Installation und Betrieb siehe [README.md](README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Konten und Zugang
|
||||||
|
|
||||||
|
### 0.1 Betriebsarten
|
||||||
|
|
||||||
|
Beim ersten Aufruf führt `/setup` durch die Einrichtung des Administratorkontos. Dort — und
|
||||||
|
später jederzeit unter *Admin-Einstellungen* — wird die Betriebsart gewählt:
|
||||||
|
|
||||||
|
| Modus | Verhalten |
|
||||||
|
| --- | --- |
|
||||||
|
| **Einzelbetrieb** | Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung. |
|
||||||
|
| **Mehrbenutzerbetrieb** | Beliebig viele Konten mit strikt getrennten Daten. Selbstregistrierung optional zuschaltbar. |
|
||||||
|
|
||||||
|
Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein Konto existiert —
|
||||||
|
sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft gesperrt.
|
||||||
|
|
||||||
|
### 0.2 Anmeldung
|
||||||
|
|
||||||
|
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden
|
||||||
|
abgelehnt. Hashing mit scrypt.
|
||||||
|
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
|
||||||
|
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
|
||||||
|
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
|
||||||
|
- **Zwei-Faktor per TOTP.** Zeitbasiertes Einmalkennwort zusätzlich zum Passwort, für jede
|
||||||
|
gängige Authenticator-App. Einrichtung über QR-Code oder abgetippten Schlüssel; scharf
|
||||||
|
wird er erst nach einem bestätigten Code, damit sich niemand aussperrt, der den Scan
|
||||||
|
nicht hinbekommt. Dazu zehn **Wiederherstellungscodes**, jeder genau einmal gültig —
|
||||||
|
sie werden nur bei der Ausgabe angezeigt. Abschalten und Neuerzeugen verlangen das
|
||||||
|
Passwort, nicht bloß eine offene Sitzung.
|
||||||
|
- Die Passkey-Anmeldung fragt **nicht** nach dem zweiten Faktor: der Passkey ist bereits an
|
||||||
|
ein Gerät gebunden und damit selbst der zweite Faktor.
|
||||||
|
- Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich
|
||||||
|
dann nicht entfernen, solange kein Passwort gesetzt ist.
|
||||||
|
- Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten.
|
||||||
|
- Die Fehlermeldung ist immer dieselbe und die Antwortzeit gleich lang, egal ob es den
|
||||||
|
Benutzernamen gibt — so lässt sich nicht ausprobieren, welche Konten existieren.
|
||||||
|
|
||||||
|
### 0.3 Mein Konto
|
||||||
|
|
||||||
|
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Zwei-Faktor einrichten und
|
||||||
|
abschalten · Passkeys verwalten · aktive Sitzungen einsehen (Browser, Betriebssystem, IP,
|
||||||
|
letzte Aktivität) · überall abmelden, wobei das eigene Gerät angemeldet bleibt.
|
||||||
|
|
||||||
|
### 0.4 Benutzerverwaltung (Administratoren)
|
||||||
|
|
||||||
|
- Konten anlegen, umbenennen, Rolle wechseln, deaktivieren, löschen
|
||||||
|
- Passwort eines Kontos neu setzen — beendet dessen Sitzungen sofort
|
||||||
|
- Übersicht je Konto: Projektzahl, Passkeys, letzte Anmeldung, Sperrstatus
|
||||||
|
- Selbstregistrierung an- und abschaltbar
|
||||||
|
- Schutzregeln: Der letzte aktive Administrator kann weder herabgestuft, deaktiviert noch
|
||||||
|
gelöscht werden; das eigene Konto lässt sich nicht löschen oder deaktivieren.
|
||||||
|
- **Administratoren sehen keine fremden Projekte.** Sie verwalten Konten, nicht Inhalte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Projekte
|
||||||
|
|
||||||
|
### 1.1 Anlegen und Verwalten
|
||||||
|
|
||||||
|
- **Idee genügt.** Ein Projekt braucht nur einen Namen. Alles Weitere — Beschreibung,
|
||||||
|
Repository, Tags — lässt sich jederzeit nachtragen. Neue Projekte starten deshalb
|
||||||
|
standardmäßig im Status *Idee*.
|
||||||
|
- **Bearbeiten** über einen Dialog, der auf dem Dashboard und der Projektseite
|
||||||
|
identisch funktioniert.
|
||||||
|
- **Löschen** mit Rückfrage; zugehörige Notizen und der Repo-Snapshot verschwinden mit
|
||||||
|
(Fremdschlüssel mit `ON DELETE CASCADE`).
|
||||||
|
- **Slug** wird automatisch aus dem Namen erzeugt (umlautfest) und bei Namenskollision
|
||||||
|
durchnummeriert.
|
||||||
|
|
||||||
|
### 1.2 Felder
|
||||||
|
|
||||||
|
| Feld | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | Pflichtfeld |
|
||||||
|
| Kurzbeschreibung | Ein Satz für die Karte im Board |
|
||||||
|
| Beschreibung | Ausführliche Ideenskizze, Zeilenumbrüche bleiben erhalten |
|
||||||
|
| Status | Idee · In Planung · Offen · In Entwicklung · Fertig · Archiviert |
|
||||||
|
| Priorität | Niedrig · Normal · Hoch · Kritisch |
|
||||||
|
| Fortschritt | 0–100 %, per Regler |
|
||||||
|
| Akzentfarbe | Sechs Farbverläufe, prägen Karte und Detailseite |
|
||||||
|
| Tags | Kommagetrennt, bis zu 12 Stück, kleingeschrieben und dedupliziert |
|
||||||
|
| Favorit | Anpinnen — Favoriten stehen im Board immer oben |
|
||||||
|
| Git-Verbindung | Optionale Zuordnung zu einem Git-System |
|
||||||
|
| Repository | URL sowie Owner/Name (werden aus der URL abgeleitet) |
|
||||||
|
|
||||||
|
### 1.3 Status
|
||||||
|
|
||||||
|
Sechs Zustände, jeder mit eigener Farbe und Erklärung im Auswahlmenü:
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Idee** | Noch ein Gedanke — nichts gebaut, nichts entschieden |
|
||||||
|
| **In Planung** | Scope, Stack und Schnitt werden festgelegt |
|
||||||
|
| **Offen** | Startklar, liegt aber gerade still |
|
||||||
|
| **In Entwicklung** | Wird aktiv gebaut |
|
||||||
|
| **Fertig** | Ausgeliefert und abgeschlossen |
|
||||||
|
| **Archiviert** | Ruht — bleibt erhalten, ist im Board aber ausgeblendet |
|
||||||
|
|
||||||
|
Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailbereich zu
|
||||||
|
öffnen. Die Änderung wird optimistisch angezeigt und bei einem Fehler zurückgerollt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|
### Schnellerfassung
|
||||||
|
|
||||||
|
Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig —
|
||||||
|
als **Idee** entsteht ein Projekt im Status `IDEA`, als **Aufgabe** eine Aufgabe im
|
||||||
|
gewählten Projekt (`TODO`). Alles Weitere setzen die Vorgaben des Schemas.
|
||||||
|
|
||||||
|
Der Dialog **bleibt nach dem Anlegen offen** und leert nur das Feld. Wer eine Idee
|
||||||
|
notiert, hat meistens gleich die nächste; ihn jedes Mal neu zu öffnen wäre genau die
|
||||||
|
Hürde, die hier wegsoll. Angelegtes wird darunter mit Link bestätigt. Das zuletzt
|
||||||
|
gewählte Projekt wird gemerkt.
|
||||||
|
|
||||||
|
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts —
|
||||||
|
dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
|
||||||
|
|
||||||
|
### Überblick
|
||||||
|
|
||||||
|
- **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive
|
||||||
|
Entwicklungen, fertige Projekte, verknüpfte Repositories. Auf dem Telefon zweispaltig,
|
||||||
|
damit die Projekte nicht erst nach dem zweiten Wischen beginnen.
|
||||||
|
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
|
||||||
|
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
|
||||||
|
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
|
||||||
|
Favoriten bleiben stets oben.
|
||||||
|
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
|
||||||
|
oder **Kanban**.
|
||||||
|
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
|
||||||
|
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
|
||||||
|
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
|
||||||
|
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene
|
||||||
|
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich
|
||||||
|
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks".
|
||||||
|
- **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
|
||||||
|
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
|
||||||
|
|
||||||
|
### Schnellsuche
|
||||||
|
|
||||||
|
**`Strg+K`** (auf dem Mac `Cmd+K`) öffnet überall eine Palette:
|
||||||
|
|
||||||
|
- **Projekte** nach Name, Kurzbeschreibung und Tags — ohne Eingabe stehen die letzten
|
||||||
|
gleich bereit
|
||||||
|
- **Notizen** über dieselbe Volltextsuche wie im Board, entprellt ab zwei Zeichen
|
||||||
|
- **Bereiche**: Dashboard, Git-Verbindungen, Mein Konto, Admin-Einstellungen
|
||||||
|
|
||||||
|
Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die Projektliste
|
||||||
|
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
|
||||||
|
wäre Verschwendung.
|
||||||
|
|
||||||
|
### Projektvorlagen
|
||||||
|
|
||||||
|
Ein Projekt, das sich bewährt hat, lässt sich im Projektkopf über **Als Vorlage sichern**
|
||||||
|
festhalten: Status, Priorität, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen eines
|
||||||
|
neuen Projekts steht die Vorlage oben im Dialog zur Auswahl.
|
||||||
|
|
||||||
|
Bewusst benutzereigen statt fest eingebaut — welche Tags und welche Startaufgaben sinnvoll
|
||||||
|
sind, weiß nur, wer damit arbeitet. Eine mitgelieferte Liste wäre geraten.
|
||||||
|
|
||||||
|
- **Erledigte Aufgaben kommen nicht mit.** Eine Vorlage beschreibt, was zu tun ist, nicht
|
||||||
|
was einmal getan wurde.
|
||||||
|
- **Repositories gehen nicht mit.** Eine Vorlage, die jedes neue Projekt an dasselbe
|
||||||
|
Repository hängt, wäre eine Falle.
|
||||||
|
- Die Stammdaten füllt der Dialog **sichtbar** in die Felder, damit sie vor dem Speichern
|
||||||
|
noch änderbar sind; bereits Getipptes wird nicht überschrieben. Notizen und Aufgaben legt
|
||||||
|
erst der Server an — im Dialog ließen sie sich nicht darstellen.
|
||||||
|
- Vorlagen überleben das Löschen ihres Ursprungsprojekts.
|
||||||
|
|
||||||
|
### Mehrfachauswahl
|
||||||
|
|
||||||
|
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
|
||||||
|
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
|
||||||
|
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
|
||||||
|
|
||||||
|
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
|
||||||
|
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
|
||||||
|
|
||||||
|
### Liste
|
||||||
|
|
||||||
|
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
|
||||||
|
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
|
||||||
|
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
|
||||||
|
|
||||||
|
Gedacht ist die Ansicht für zwei Fälle:
|
||||||
|
|
||||||
|
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
|
||||||
|
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
|
||||||
|
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
|
||||||
|
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
|
||||||
|
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
|
||||||
|
|
||||||
|
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
|
||||||
|
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
|
||||||
|
|
||||||
|
### Kanban
|
||||||
|
|
||||||
|
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
|
||||||
|
erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
|
||||||
|
|
||||||
|
- Gezogen wird am Griff links, nicht an der ganzen Karte — so bleibt der Titel ein
|
||||||
|
normaler Link.
|
||||||
|
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
|
||||||
|
Auf Touchgeräten nach kurzem Halten.
|
||||||
|
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
|
||||||
|
wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
|
||||||
|
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
|
||||||
|
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
|
||||||
|
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
|
||||||
|
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
|
||||||
|
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
|
||||||
|
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
|
||||||
|
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
|
||||||
|
|
||||||
|
### Projektkarte auf einen Blick
|
||||||
|
|
||||||
|
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
|
||||||
|
Default-Branch samt Anzahl weiterer Branches · letzter Tag · Alter des letzten Commits ·
|
||||||
|
Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2a. Aufgaben je Projekt
|
||||||
|
|
||||||
|
Jedes Projekt hat ein eigenes Aufgabenbrett mit vier Spalten: **Offen · In Arbeit ·
|
||||||
|
Blockiert · Erledigt**.
|
||||||
|
|
||||||
|
- **Ziehen und Ablegen** zwischen den Spalten **und innerhalb** einer Spalte. Anders als
|
||||||
|
beim Projektbrett ist die Reihenfolge hier bedeutsam, deshalb wird sie gespeichert.
|
||||||
|
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den Dialog öffnet.
|
||||||
|
- Eine Aufgabe braucht nur einen Titel. Optional: Beschreibung (Markdown),
|
||||||
|
Fälligkeitsdatum und Labels.
|
||||||
|
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
|
||||||
|
gedämpft.
|
||||||
|
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
|
||||||
|
wieder gelöscht.
|
||||||
|
|
||||||
|
### Wiederkehrende Aufgaben
|
||||||
|
|
||||||
|
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
|
||||||
|
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
|
||||||
|
erledigte bleibt stehen, sonst ginge die Historie verloren.
|
||||||
|
|
||||||
|
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche Aufgabe
|
||||||
|
bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch erledigt wurde.
|
||||||
|
- Lange Liegengebliebenes rutscht so weit vor, bis der Termin in der Zukunft liegt — eine
|
||||||
|
Aufgabe, die schon bei ihrer Entstehung überfällig ist, hilft niemandem.
|
||||||
|
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
|
||||||
|
28. Februar, nicht zum 3. März.
|
||||||
|
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
|
||||||
|
speichern erzeugt keine zweite Fassung.
|
||||||
|
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
|
||||||
|
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
|
||||||
|
|
||||||
|
### Aus Git-Issues übernehmen
|
||||||
|
|
||||||
|
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
|
||||||
|
Labels und ein Link zurück ins Git-System.
|
||||||
|
|
||||||
|
- Grundlage ist der **zuletzt geholte Stand**, hier wird nichts nachgeladen. Ein Klick
|
||||||
|
soll nicht ungefragt fremde APIs ansprechen; wer den neuesten Stand will, gleicht
|
||||||
|
vorher ab.
|
||||||
|
- Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein zweiter Klick
|
||||||
|
legt deshalb **keine Dubletten** an, auch wenn die Aufgabe inzwischen verschoben oder
|
||||||
|
umbenannt wurde.
|
||||||
|
- Ohne Repo-Stand meldet der Server das mit einem Hinweis statt still nichts zu tun.
|
||||||
|
|
||||||
|
### Fortschritt aus Aufgaben
|
||||||
|
|
||||||
|
Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter
|
||||||
|
Aufgaben und wird bei jeder Änderung neu berechnet — der Regler ist dann gesperrt, statt
|
||||||
|
einen Wert anzubieten, der gleich wieder überschrieben würde.
|
||||||
|
|
||||||
|
Auf der Projektkarte im Dashboard steht das Verhältnis als `3/8`.
|
||||||
|
|
||||||
|
### Wie die Reihenfolge gespeichert wird
|
||||||
|
|
||||||
|
Beim Ablegen schickt die Oberfläche je Spalte die **vollständige Liste** der Aufgaben in
|
||||||
|
ihrer neuen Reihenfolge; der Server vergibt daraus Position und Status. Das ist
|
||||||
|
wiederholbar, kommt ohne Positionsrechnerei im Browser aus, und ein verlorener Aufruf
|
||||||
|
lässt sich einfach erneut senden. IDs, die nicht zum Projekt gehören, werden ignoriert
|
||||||
|
statt verschoben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. Aufgaben über alle Projekte
|
||||||
|
|
||||||
|
Die Seite `/tasks` sammelt jede Aufgabe aus jedem Projekt an einer Stelle. Erreichbar
|
||||||
|
über die Kopfleiste und die Schnellsuche.
|
||||||
|
|
||||||
|
**Geordnet wird nach Fälligkeit, nicht nach Spalte.** Projektübergreifend lautet die
|
||||||
|
Frage „was ist überfällig", nicht „was steht wo im Brett". Die Fächer:
|
||||||
|
|
||||||
|
| Fach | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Überfällig** | Termin liegt vor heute |
|
||||||
|
| **Heute** | Termin ist der heutige Kalendertag |
|
||||||
|
| **Diese Woche** | in den nächsten sieben Tagen |
|
||||||
|
| **Später** | alles danach |
|
||||||
|
| **Ohne Termin** | kein Fälligkeitsdatum gesetzt |
|
||||||
|
|
||||||
|
Gerechnet wird auf **Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
|
||||||
|
Stunden und nicht nach Serverzeit. Eine heute um 9 Uhr fällige Aufgabe ist um 17 Uhr
|
||||||
|
nicht überfällig, sondern immer noch „heute" — sonst wanderte die halbe Liste im Lauf
|
||||||
|
des Tages nach oben. Und im Container läuft die Anwendung nach UTC; ohne feste Zeitzone
|
||||||
|
kippte die Einteilung abends um einen Tag. Das Fach bestimmt der **Server**, damit
|
||||||
|
Server-Render und Hydration nicht auseinanderlaufen.
|
||||||
|
|
||||||
|
- **Filter** nach Status (mit Zähler) und Projekt; Erledigtes ist ausgeblendet und
|
||||||
|
einzeln zuschaltbar.
|
||||||
|
- **Abhaken direkt aus der Liste.** Der Endpunkt erledigt dabei den Rest: Zeitpunkt,
|
||||||
|
abgeleiteter Projektfortschritt und — bei wiederkehrenden Aufgaben — die nächste
|
||||||
|
Fassung.
|
||||||
|
- Jede Zeile nennt ihr **Projekt** und verlinkt es; ohne das ist eine übergreifende
|
||||||
|
Liste nicht zu lesen. Stammt die Aufgabe aus einem Git-Issue, führt ein Symbol dorthin.
|
||||||
|
|
||||||
|
**Bewusst nicht sortierbar per Ziehen.** Die Position einer Aufgabe gilt innerhalb ihrer
|
||||||
|
Spalte in ihrem Projekt; hier wäre sie ohne Bedeutung. Eine Reihenfolge anzubieten, die
|
||||||
|
nirgends ankommt, wäre ein Versprechen, das die Seite nicht halten kann.
|
||||||
|
|
||||||
|
Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
|
||||||
|
(`project: { ownerId }` als einzige und erste Bedingung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Notizen
|
||||||
|
|
||||||
|
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
|
||||||
|
- **Markdown wird dargestellt** — Überschriften, Listen, Tabellen, Codeblöcke, Zitate und
|
||||||
|
Links. Aus `- [ ] Aufgabe` werden echte Checklisten. Gilt auch für die Projekt-
|
||||||
|
beschreibung. Rohes HTML wird beim Rendern entfernt (`rehype-sanitize`) — der Inhalt
|
||||||
|
stammt vom Benutzer und wäre sonst ein XSS-Loch.
|
||||||
|
- **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
|
||||||
|
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
|
||||||
|
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
|
||||||
|
- Zeilenumbrüche und Einrückungen bleiben erhalten — Checklisten und Code-Schnipsel
|
||||||
|
überleben das Speichern.
|
||||||
|
- Eine neue Notiz aktualisiert die Änderungszeit des Projekts, damit die Sortierung
|
||||||
|
„zuletzt geändert" die tatsächliche Aktivität abbildet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Git-Anbindung
|
||||||
|
|
||||||
|
### 4.0 Mehrere Repositories je Projekt
|
||||||
|
|
||||||
|
Ein Vorhaben besteht oft aus mehreren Repositories — Frontend und Backend etwa, oder
|
||||||
|
App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
|
||||||
|
|
||||||
|
- Im Git-Panel steht eine Leiste mit allen Repositories des Projekts, sobald es mehr als
|
||||||
|
eines gibt. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich; „Alle" gleicht
|
||||||
|
sie nacheinander ab — nicht gleichzeitig, denn ein Schwung Anfragen an denselben Anbieter
|
||||||
|
ist genau das, worauf Rate-Limits reagieren.
|
||||||
|
- Eine frei wählbare **Bezeichnung** („Frontend") tritt an die Stelle von `owner/name`.
|
||||||
|
- Das erste Repository ist das **führende**. Wo nur eines dargestellt werden kann — auf der
|
||||||
|
Karte im Dashboard, im Kanban —, ist es dieses. Die Karte zeigt daneben `+2 Repos`.
|
||||||
|
Ein Abgleichsfehler an *irgendeinem* Repository erscheint auf der Karte, sonst bliebe er
|
||||||
|
unbemerkt.
|
||||||
|
- **Stillstand** bemisst sich am jüngsten Commit über alle Repositories: an einem Vorhaben
|
||||||
|
wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.
|
||||||
|
- Benachrichtigungen merken sich den gemeldeten Zustand **je Repository**. Sonst verdeckte
|
||||||
|
ein rotes Frontend die rote Pipeline des Backends. Der Betreff nennt das Repository nur,
|
||||||
|
wenn es nicht ohnehin so heißt wie das Projekt.
|
||||||
|
- Übernommene Issues merken sich, aus welchem Repository sie stammen — Frontend und Backend
|
||||||
|
haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
|
||||||
|
|
||||||
|
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
|
||||||
|
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
|
||||||
|
|
||||||
|
### 4.0a README-Vorschau
|
||||||
|
|
||||||
|
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
|
||||||
|
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
|
||||||
|
READMEs sind eingeklappt, ein Knopf zeigt alles.
|
||||||
|
|
||||||
|
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
|
||||||
|
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
|
||||||
|
(Wurzelverzeichnis auflisten, dann die Datei holen).
|
||||||
|
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
|
||||||
|
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
|
||||||
|
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
|
||||||
|
Codeblock stehen bleibt.
|
||||||
|
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
|
||||||
|
liefen sie ins Leere.
|
||||||
|
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
|
||||||
|
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
|
||||||
|
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
|
||||||
|
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
|
||||||
|
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
|
||||||
|
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
|
||||||
|
Bereinigung wie Notizen (`rehype-sanitize`).
|
||||||
|
|
||||||
|
### 4.1 Unterstützte Systeme
|
||||||
|
|
||||||
|
| System | API | Authentifizierung | Besonderheit |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **GitHub** | REST v3 | `Authorization: Bearer` | github.com und GitHub Enterprise (`/api/v3`) |
|
||||||
|
| **GitLab** | API v4 | `PRIVATE-TOKEN` | gitlab.com und Self-Hosted, verschachtelte Gruppen |
|
||||||
|
| **Gitea / Forgejo** | API v1 | `Authorization: token` | beliebige eigene Instanz |
|
||||||
|
|
||||||
|
Mehrere Verbindungen desselben Typs sind möglich — etwa privates GitHub und
|
||||||
|
Firmen-Gitea nebeneinander.
|
||||||
|
|
||||||
|
### 4.2 Verbindungen verwalten
|
||||||
|
|
||||||
|
- Anlegen mit Anzeigename, System, Basis-URL und Access Token.
|
||||||
|
- **Verbindungstest** auf Knopfdruck: prüft Erreichbarkeit und Token, zeigt den erkannten
|
||||||
|
Benutzer und bei Gitea zusätzlich die Serverversion. Ergebnis und Zeitpunkt werden
|
||||||
|
gespeichert und in der Karte angezeigt.
|
||||||
|
- **Token-Verwaltung**: verschlüsselt gespeichert, in der UI nur als Hinweis (`ghp_…9f3a`)
|
||||||
|
sichtbar. Beim Bearbeiten bedeutet ein leeres Feld „unverändert lassen".
|
||||||
|
- **Repository-Auswahl**: Nebula listet alle Repos, auf die der Token Zugriff hat, mit
|
||||||
|
Suchfeld. Ein Klick übernimmt URL, Owner, Name und — falls noch leer — Projektname und
|
||||||
|
Kurzbeschreibung.
|
||||||
|
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
|
||||||
|
|
||||||
|
### 4.3 Was beim Sync geholt wird
|
||||||
|
|
||||||
|
| Bereich | Details |
|
||||||
|
| --- | --- |
|
||||||
|
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
|
||||||
|
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
|
||||||
|
| **Pull-/Merge-Requests** | bis zu 50 offene, mit Nummer, Titel, Autor, Alter, Labels, Entwurfskennzeichnung und Link |
|
||||||
|
| **Issues** | bis zu 50 offene, gleiche Angaben. Bei GitHub und Gitea werden PRs herausgefiltert, die dort in derselben Liste stecken |
|
||||||
|
| **CI-Status** | Letzter Lauf auf dem Default-Branch: erfolgreich, fehlgeschlagen, läuft, wartet oder abgebrochen — mit Name und Link |
|
||||||
|
| Branches | bis zu 50, mit SHA, Default-Markierung, Schutzstatus und (wo die API es liefert) letztem Commit |
|
||||||
|
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
|
||||||
|
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
|
||||||
|
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
|
||||||
|
| **Commit-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
|
||||||
|
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
|
||||||
|
| Sprachen | Verteilung als Balken mit Prozentwerten |
|
||||||
|
|
||||||
|
Woher der CI-Status kommt:
|
||||||
|
|
||||||
|
| System | Quelle |
|
||||||
|
| --- | --- |
|
||||||
|
| GitHub | `actions/runs` für den Default-Branch; Lauf-Status und Ergebnis werden zusammengeführt |
|
||||||
|
| GitLab | `pipelines` für den Default-Branch |
|
||||||
|
| Gitea | Kombinierter Commit-Status — deckt Gitea Actions **und** externe Systeme wie Woodpecker ab, anders als ein reiner Actions-Endpunkt, den ältere Instanzen nicht kennen |
|
||||||
|
|
||||||
|
Zur Ehrlichkeit der Aktivitätskurve: geholt wird eine Seite mit 100 Commits. Reicht die
|
||||||
|
nicht über den ganzen Zeitraum, wird die Kurve auf den tatsächlich belegten Ausschnitt
|
||||||
|
gekürzt — eine Null zu zeichnen, wo wir schlicht nichts wissen, wäre falsch.
|
||||||
|
|
||||||
|
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
||||||
|
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
||||||
|
|
||||||
|
### 4.4 Automatischer Abgleich
|
||||||
|
|
||||||
|
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
|
||||||
|
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
|
||||||
|
|
||||||
|
| Einstellung | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| Abstand | 15 Minuten bis täglich |
|
||||||
|
| Repositories je Durchlauf | 1–100, Voreinstellung 25 |
|
||||||
|
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
|
||||||
|
|
||||||
|
Gezählt und begrenzt wird je **Repository**, nicht je Projekt: sonst zöge ein Projekt mit
|
||||||
|
fünf Repos fünfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt.
|
||||||
|
|
||||||
|
Die Daten für die **Heatmap** kommen getrennt und höchstens einmal am Tag — an einem
|
||||||
|
Jahresüberblick ändert sich von Stunde zu Stunde nichts Sichtbares. Bei GitHub genügt dafür
|
||||||
|
ein Aufruf der Jahresstatistik; GitLab und Gitea kennen keine, dort werden bis zu fünf
|
||||||
|
Seiten Commits geholt. Reicht das nicht ein Jahr zurück, **beginnt die Heatmap beim
|
||||||
|
ältesten bekannten Commit** und sagt es dazu — leere Felder für Zeiträume zu zeichnen, über
|
||||||
|
die nichts bekannt ist, wäre eine Lüge.
|
||||||
|
|
||||||
|
Wie der Durchlauf arbeitet:
|
||||||
|
|
||||||
|
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
|
||||||
|
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
|
||||||
|
Cron. Zwei Durchläufe überschneiden sich nie.
|
||||||
|
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
|
||||||
|
nächsten Mal.
|
||||||
|
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
|
||||||
|
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
|
||||||
|
Token blockiert damit nicht dauerhaft das Kontingent.
|
||||||
|
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
|
||||||
|
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
|
||||||
|
synchronisieren geht unabhängig davon immer.
|
||||||
|
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
|
||||||
|
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
|
||||||
|
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||||
|
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||||
|
|
||||||
|
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
|
||||||
|
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
||||||
|
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
||||||
|
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
||||||
|
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
||||||
|
- **Fehler landen am Projekt** statt in einer Fehlerseite: die Karte zeigt „Sync-Fehler",
|
||||||
|
die Projektseite die genaue Meldung. Typische HTTP-Codes werden in Klartext übersetzt
|
||||||
|
(401 Token, 403 Scopes/Rate-Limit, 404 Pfad, 408 Timeout).
|
||||||
|
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
|
||||||
|
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4a. Export, Import und Sicherung
|
||||||
|
|
||||||
|
### Projekte mitnehmen
|
||||||
|
|
||||||
|
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
|
||||||
|
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
|
||||||
|
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
|
||||||
|
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
|
||||||
|
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
|
||||||
|
bekannte Git-Stand als lesbare Datei.
|
||||||
|
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
|
||||||
|
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
|
||||||
|
|
||||||
|
| Strategie | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| Überspringen | Vorhandene bleiben unverändert |
|
||||||
|
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
|
||||||
|
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
|
||||||
|
|
||||||
|
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
|
||||||
|
hin. Erst ein zweiter Klick schreibt.
|
||||||
|
|
||||||
|
### Sicherung
|
||||||
|
|
||||||
|
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
|
||||||
|
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
|
||||||
|
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
|
||||||
|
kurzlebig, letztere hängen an Gerät und rpID.
|
||||||
|
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
|
||||||
|
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
|
||||||
|
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
|
||||||
|
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
|
||||||
|
ohne die echte anzufassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
Raster, Neon-Akzente pro Projektfarbe.
|
||||||
|
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
|
||||||
|
animierte Fortschrittsbalken.
|
||||||
|
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
|
||||||
|
- **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
|
||||||
|
zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
|
||||||
|
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
|
||||||
|
schaltet Animationen ab.
|
||||||
|
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
|
||||||
|
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
|
||||||
|
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
|
||||||
|
zwischen ihnen bleibt sichtbar.
|
||||||
|
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
|
||||||
|
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
|
||||||
|
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
|
||||||
|
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
|
||||||
|
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
|
||||||
|
oder als Tropfen zuschneidet.
|
||||||
|
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
|
||||||
|
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
|
||||||
|
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
|
||||||
|
beiden Angaben hier schon einmal auseinandergelaufen sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Daten und Technik
|
||||||
|
|
||||||
|
### 6.1 Datenmodell
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| `User` | Konten, Rolle, Passwort-Hash, Sperrzähler |
|
||||||
|
| `Session` | Serverseitige Sitzungen (nur der Token-Hash) |
|
||||||
|
| `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler |
|
||||||
|
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
|
||||||
|
| `RecoveryCode` | Wiederherstellungscodes für die Zwei-Faktor-Anmeldung (nur der Hash) |
|
||||||
|
| `PendingLogin` | Zwischenzustand zwischen Passwort und zweitem Faktor, fünf Minuten gültig |
|
||||||
|
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
|
||||||
|
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
|
||||||
|
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
|
||||||
|
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
|
||||||
|
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
|
||||||
|
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
|
||||||
|
| `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 Repository) |
|
||||||
|
|
||||||
|
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus
|
||||||
|
der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
|
||||||
|
übernommen. Slug und Verbindungsname sind je Besitzer eindeutig — zwei Personen dürfen
|
||||||
|
beide ein Projekt „dashboard" haben.
|
||||||
|
|
||||||
|
Listen wie Branches oder Commits liegen als `JSONB`, damit providerspezifische Felder
|
||||||
|
nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
|
||||||
|
|
||||||
|
### 6.2 Sicherheit
|
||||||
|
|
||||||
|
- **Zugangsschutz in zwei Ebenen.** Die Middleware prüft nur, ob ein Sitzungscookie
|
||||||
|
überhaupt mitkommt — im Edge-Runtime gibt es keine Datenbank. Verbindlich entschieden
|
||||||
|
wird serverseitig in jeder Seite und jedem Endpunkt (`requireUserPage` /
|
||||||
|
`requireUserApi`). Das ist die eigentliche Sicherheitsgrenze.
|
||||||
|
- **Datentrennung** über eine Besitzerbedingung in jeder Abfrage. Schreibzugriffe laufen
|
||||||
|
über `updateMany`/`deleteMany` mit `ownerId` in der `where`-Klausel, treffen fremde
|
||||||
|
Datensätze also gar nicht erst. Fremde IDs liefern `404` statt `403`.
|
||||||
|
- **CSRF**: Origin- bzw. `Sec-Fetch-Site`-Prüfung für alle schreibenden Methoden, dazu
|
||||||
|
erzwungener `Content-Type: application/json`.
|
||||||
|
- **CSP mit Per-Request-Nonce** und `strict-dynamic`, plus `frame-ancestors 'none'`,
|
||||||
|
`object-src 'none'`, `base-uri 'none'` und die üblichen Schutz-Header.
|
||||||
|
- **SSRF-Schutz** vor jedem ausgehenden Git-Aufruf: Hostname wird aufgelöst, jede
|
||||||
|
Antwortadresse geprüft (schützt auch gegen DNS-Rebinding über einen zweiten A-Record),
|
||||||
|
Redirect-Ziele werden erneut geprüft. Konfigurierbar über `GIT_ALLOW_LOOPBACK`,
|
||||||
|
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
|
||||||
|
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
|
||||||
|
WebAuthn-Optionen.
|
||||||
|
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
|
||||||
|
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
|
||||||
|
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
|
||||||
|
ohne `tokenCipher`.
|
||||||
|
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI, Body auf
|
||||||
|
256 KB begrenzt.
|
||||||
|
- Prisma arbeitet ausschließlich mit parametrisierten Abfragen.
|
||||||
|
|
||||||
|
### 6.3 Betrieb
|
||||||
|
|
||||||
|
- **Zwei gleichwertige Betriebsarten**: Standalone (Node-Prozess, beliebige PostgreSQL)
|
||||||
|
oder komplett in Docker — dieselbe Codebasis, dieselbe `.env`.
|
||||||
|
- **Docker-Compose-Profile**: `db` läuft immer, `--profile full` startet zusätzlich die
|
||||||
|
App, `--profile tools` bringt Adminer als DB-Oberfläche mit.
|
||||||
|
- **Automatische Migration** beim Containerstart, inklusive Warteschleife auf die
|
||||||
|
Datenbank.
|
||||||
|
- **Healthchecks**: `pg_isready` für Postgres, `/api/health` für die App.
|
||||||
|
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
|
||||||
|
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
|
||||||
|
|
||||||
|
### 6.4 Tests
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| --- | --- |
|
||||||
|
| `utils` | Slug-Erzeugung inklusive Umlauten und Längenbegrenzung, Tag-Aufbereitung, Kürzen, Zahlenformat |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
**`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:
|
||||||
|
|
||||||
|
| 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
|
||||||
|
[README](README.md#api).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ideen für später
|
||||||
|
|
||||||
|
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
|
||||||
|
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# ✦ Nebula
|
||||||
|
|
||||||
|
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
|
||||||
|
|
||||||
|
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
|
||||||
|
|
||||||
|
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist Nebula?
|
||||||
|
|
||||||
|
Vibe Coding produziert schnell viele halbfertige Projekte: ein Repo hier, eine Idee dort,
|
||||||
|
irgendwo eine Notiz in einer Textdatei. Nebula bündelt das an einem Ort.
|
||||||
|
|
||||||
|
Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beliebig viele
|
||||||
|
**Notizen** und optional ein **verknüpftes Repository**. Nebula holt sich von dort per API
|
||||||
|
Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab**
|
||||||
|
oder eine selbst gehostete **Gitea**-Instanz.
|
||||||
|
|
||||||
|
Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula
|
||||||
|
kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in
|
||||||
|
dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in
|
||||||
|
den Admin-Einstellungen.
|
||||||
|
|
||||||
|
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
||||||
|
Access Tokens werden verschlüsselt abgelegt.
|
||||||
|
|
||||||
|
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
|
||||||
|
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. Für den Dauerbetrieb auf einem
|
||||||
|
Server — TLS, Firewall, Sicherungen und Updates — gibt es **[DEPLOYMENT.md](DEPLOYMENT.md)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
Zwei Betriebsarten, dieselbe Codebasis. Beide brauchen zuerst eine `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ohne Node zur Hand? Einfach `.env.example` nach `.env` kopieren und `APP_SECRET` von Hand
|
||||||
|
> auf einen langen Zufallswert setzen.
|
||||||
|
|
||||||
|
### Variante A — Standalone (empfohlen zum Entwickeln)
|
||||||
|
|
||||||
|
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docker:db # Postgres 17 auf 127.0.0.1:5432
|
||||||
|
npm install
|
||||||
|
npm run db:migrate # Schema anlegen
|
||||||
|
npm run db:seed # optional: drei Beispielprojekte
|
||||||
|
npm run dev # → http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Für den Produktivbetrieb ohne Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start # → http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variante B — Alles in Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full up -d --build # → http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Der App-Container spielt beim Start automatisch die Migrationen ein und wartet dabei auf
|
||||||
|
die Datenbank. Logs mitlesen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
Stoppen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile full down # Container weg, Daten bleiben
|
||||||
|
docker compose --profile full down -v # inkl. Datenbank-Volume
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variante C — Eigene, bereits laufende PostgreSQL
|
||||||
|
|
||||||
|
Docker wird gar nicht gebraucht. In der `.env` nur den Connection-String setzen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://benutzer:passwort@dbhost:5432/nebula?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `npm install && npm run db:migrate && npm run dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Variante | Benötigt |
|
||||||
|
| --- | --- |
|
||||||
|
| Standalone | Node.js ≥ 20.11, npm, PostgreSQL ≥ 14 (lokal oder via Docker) |
|
||||||
|
| Docker | Docker Engine ≥ 24 mit Compose v2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
||||||
|
|
||||||
|
| Variable | Bedeutung | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `APP_NAME` | Anzeigename: Seitentitel, Kopfzeile, Passkey-Dialog | `Nebula` |
|
||||||
|
| `APP_TAGLINE` | Kleine Zeile unter dem Namen | `Vibe Coding` |
|
||||||
|
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
|
||||||
|
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
|
||||||
|
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
|
||||||
|
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
|
||||||
|
| `PORT` | Port der Anwendung | `3000` |
|
||||||
|
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
||||||
|
| `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
|
||||||
|
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
|
||||||
|
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
|
||||||
|
| `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
|
||||||
|
betreiben — Container neu starten genügt.
|
||||||
|
|
||||||
|
> **`APP_SECRET` nachträglich ändern** macht bereits gespeicherte Git-Tokens unlesbar.
|
||||||
|
> Sie müssen dann in den Verbindungen neu hinterlegt werden — Konten, Passwörter und
|
||||||
|
> Passkeys bleiben unberührt.
|
||||||
|
|
||||||
|
> **`APP_URL` nachträglich ändern** (anderer Hostname) macht bestehende **Passkeys**
|
||||||
|
> ungültig, weil sie an die RP-ID gebunden sind. Passwörter funktionieren weiter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erste Anmeldung
|
||||||
|
|
||||||
|
Beim ersten Aufruf landest du auf `/setup`. Dort legst du das Administratorkonto an und
|
||||||
|
wählst die Betriebsart:
|
||||||
|
|
||||||
|
- **Einzelbetrieb** — genau ein Konto, keine Registrierung, keine Benutzerliste.
|
||||||
|
- **Mehrbenutzerbetrieb** — beliebig viele Konten. Projekte, Notizen und Git-Verbindungen
|
||||||
|
sind pro Konto strikt getrennt; auch ein Administrator sieht fremde Projekte nicht.
|
||||||
|
|
||||||
|
Nach der Einrichtung ist `/setup` gesperrt. Bereits vorhandene Projekte ohne Besitzer
|
||||||
|
(etwa aus den Seed-Daten) werden dem neuen Konto zugeordnet.
|
||||||
|
|
||||||
|
**Passkey einrichten:** unter *Mein Konto → Passkeys → Passkey hinzufügen*. Danach genügt
|
||||||
|
auf der Anmeldeseite ein Klick auf „Mit Passkey anmelden" — ohne Benutzernamen, weil
|
||||||
|
Nebula auffindbare Credentials nutzt.
|
||||||
|
|
||||||
|
> Passkeys brauchen einen sicheren Kontext. Sie funktionieren über `https://` und über
|
||||||
|
> `http://localhost`, **nicht** über eine LAN-IP ohne TLS. `APP_URL` muss dabei exakt der
|
||||||
|
> Adresse entsprechen, unter der du die App aufrufst.
|
||||||
|
|
||||||
|
## Git-Verbindung einrichten
|
||||||
|
|
||||||
|
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen:
|
||||||
|
|
||||||
|
| System | Scopes |
|
||||||
|
| --- | --- |
|
||||||
|
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
|
||||||
|
| GitLab | `read_api` (alternativ `read_repository`) |
|
||||||
|
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
|
||||||
|
|
||||||
|
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
|
||||||
|
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
|
||||||
|
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
|
||||||
|
4. Beim Anlegen oder Bearbeiten eines Projekts die Verbindung auswählen und über
|
||||||
|
**Repos laden** das Repository aussuchen (oder die URL direkt eintippen).
|
||||||
|
5. Auf der Projektseite **Jetzt synchronisieren** — Branches, Tags, Commits und Releases
|
||||||
|
werden geholt und als Snapshot gespeichert.
|
||||||
|
|
||||||
|
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
||||||
|
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
||||||
|
|
||||||
|
### Automatisch statt auf Knopfdruck
|
||||||
|
|
||||||
|
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
|
||||||
|
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
|
||||||
|
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
|
||||||
|
jeder Durchlauf API-Kontingent verbraucht.
|
||||||
|
|
||||||
|
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
|
||||||
|
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
|
||||||
|
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
|
||||||
|
Projektdialog ausnehmen; manuell synchronisieren geht immer.
|
||||||
|
|
||||||
|
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||||
|
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export, Import und Sicherung
|
||||||
|
|
||||||
|
### Projekte mitnehmen
|
||||||
|
|
||||||
|
| Weg | Wo | Inhalt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
|
||||||
|
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
|
||||||
|
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
|
||||||
|
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
|
||||||
|
|
||||||
|
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
|
||||||
|
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
|
||||||
|
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
|
||||||
|
angelegt und in der Vorschau angezeigt.
|
||||||
|
|
||||||
|
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
|
||||||
|
|
||||||
|
- **Überspringen** — Vorhandene bleiben unverändert
|
||||||
|
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
|
||||||
|
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
|
||||||
|
|
||||||
|
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
|
||||||
|
|
||||||
|
### Sicherung
|
||||||
|
|
||||||
|
Zwei Wege, mit unterschiedlichem Zweck:
|
||||||
|
|
||||||
|
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
|
||||||
|
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
|
||||||
|
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
|
||||||
|
vertraulich behandeln.
|
||||||
|
|
||||||
|
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
|
||||||
|
Instanz braucht:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
|
||||||
|
.\scripts\restore.ps1 -File .\backups\nebula-....sql
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
|
||||||
|
./scripts/restore.sh backups/nebula-....sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
|
||||||
|
Container aus — auf dem Rechner selbst muss nichts installiert sein.
|
||||||
|
|
||||||
|
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
|
||||||
|
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
|
||||||
|
>
|
||||||
|
> ```powershell
|
||||||
|
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||||
|
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
nebula/
|
||||||
|
├── docker/entrypoint.sh Migrationen + Serverstart im Container
|
||||||
|
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
|
||||||
|
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma Datenmodell
|
||||||
|
│ ├── migrations/ versionierte SQL-Migrationen
|
||||||
|
│ └── seed.ts Beispieldaten
|
||||||
|
├── scripts/setup.mjs .env erzeugen inkl. APP_SECRET
|
||||||
|
└── src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx Dashboard (Board, Filter, Kennzahlen)
|
||||||
|
│ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Git
|
||||||
|
│ ├── connections/ Verwaltung der Git-Verbindungen
|
||||||
|
│ └── api/ REST-Endpunkte (Projekte, Notizen, Verbindungen, Sync)
|
||||||
|
├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge)
|
||||||
|
├── lib/
|
||||||
|
│ ├── crypto.ts AES-256-GCM für Access Tokens
|
||||||
|
│ ├── db.ts Prisma-Client
|
||||||
|
│ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts
|
||||||
|
│ ├── sync.ts Repo-Snapshot holen und speichern
|
||||||
|
│ └── validation.ts Zod-Schemas für alle Eingaben
|
||||||
|
└── types/ gemeinsame Typen für Server und Client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm-Skripte
|
||||||
|
|
||||||
|
| Skript | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Entwicklungsserver mit Hot Reload |
|
||||||
|
| `npm run build` / `npm start` | Produktionsbuild und -start (ohne Docker) |
|
||||||
|
| `npm run setup` | `.env` anlegen inkl. generiertem `APP_SECRET` |
|
||||||
|
| `npm run db:migrate` | Migrationen einspielen (`prisma migrate deploy`) |
|
||||||
|
| `npm run db:migrate:dev` | Neue Migration aus Schemaänderungen erzeugen |
|
||||||
|
| `npm run db:seed` | Beispielprojekte anlegen |
|
||||||
|
| `npm run db:studio` | Prisma Studio öffnen |
|
||||||
|
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
|
||||||
|
| `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, mit Host-Port für `npm run dev` |
|
||||||
|
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
|
||||||
|
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Alle Endpunkte liefern und erwarten JSON.
|
||||||
|
|
||||||
|
Bis auf `/api/health` und `/api/auth/*` erfordern alle Endpunkte eine Anmeldung und liefern
|
||||||
|
ausschließlich Daten des angemeldeten Kontos.
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
|
||||||
|
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
|
||||||
|
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
|
||||||
|
| `POST` | `/api/auth/mfa` | Zweiter Faktor: Einmalkennwort oder Wiederherstellungscode |
|
||||||
|
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
|
||||||
|
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
|
||||||
|
| `PATCH` | `/api/account/profile` | Eigenes Profil |
|
||||||
|
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
||||||
|
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
||||||
|
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
||||||
|
| `GET` `POST` `PUT` `DELETE` | `/api/account/totp` | Zwei-Faktor: Zustand, einrichten, aktivieren, abschalten |
|
||||||
|
| `POST` | `/api/account/totp/recovery` | Neue Wiederherstellungscodes |
|
||||||
|
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
|
||||||
|
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
|
||||||
|
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
||||||
|
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||||
|
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||||
|
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
|
||||||
|
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
|
||||||
|
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
|
||||||
|
| `GET` `POST` | `/api/templates` | Projektvorlagen auflisten / anlegen |
|
||||||
|
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
|
||||||
|
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
|
||||||
|
| `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` | Alle Repositories des Projekts abgleichen |
|
||||||
|
| `PATCH` | `/api/projects/reorder` | Neue Reihenfolge im Brett |
|
||||||
|
| `POST` | `/api/projects/bulk` | Status, Tags oder Löschen für mehrere |
|
||||||
|
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
|
||||||
|
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
|
||||||
|
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
|
||||||
|
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
|
||||||
|
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
|
||||||
|
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
|
||||||
|
| `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen |
|
||||||
|
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
|
||||||
|
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
|
||||||
|
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
|
||||||
|
| `GET` `POST` | `/api/connections` | Git-Verbindungen |
|
||||||
|
| `PATCH` `DELETE` | `/api/connections/:id` | Verbindung ändern / löschen |
|
||||||
|
| `POST` | `/api/connections/:id/check` | Verbindung und Token testen |
|
||||||
|
| `GET` | `/api/connections/:id/repos` | Zugängliche Repositories auflisten |
|
||||||
|
|
||||||
|
Beispiel — Idee per Kommandozeile anlegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/projects \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Neue Idee","status":"IDEA","tags":["experiment"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
| Bereich | Umsetzung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Anmeldung** | Passwort-Hashing mit **scrypt** (N=32768, r=8, p=1), serverseitige Sitzungen — in der Datenbank liegt nur der SHA-256-Hash des Cookie-Tokens |
|
||||||
|
| **Passkeys** | WebAuthn über SimpleWebAuthn, Challenges einmalig verwendbar und in der DB abgelegt, Signaturzähler wird fortgeschrieben |
|
||||||
|
| **Cookies** | `HttpOnly`, `SameSite=Lax`, `Secure` automatisch bei https |
|
||||||
|
| **CSRF** | Jede schreibende Anfrage muss vom eigenen Origin kommen (`Sec-Fetch-Site` / `Origin`); zusätzlich wird `Content-Type: application/json` erzwungen, was den Formular-Trick ohne Preflight ausschließt |
|
||||||
|
| **CSP** | Strikte Policy mit Per-Request-**Nonce** und `strict-dynamic`, dazu `frame-ancestors 'none'`, `object-src 'none'`, `base-uri 'none'` |
|
||||||
|
| **Weitere Header** | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` |
|
||||||
|
| **SSRF** | Die Basis-URL einer Git-Verbindung ist Benutzereingabe. Vor jedem ausgehenden Aufruf werden Hostname und **alle** aufgelösten IPs geprüft (auch nach Redirects); Loopback, Link-Local samt Cloud-Metadaten, Multicast und CGNAT sind gesperrt |
|
||||||
|
| **Datentrennung** | Jede Abfrage filtert auf den Besitzer. Fremde IDs liefern `404`, nicht `403` — die Antwort verrät also nicht einmal, dass es den Datensatz gibt |
|
||||||
|
| **Rate-Limiting** | Anmeldung pro IP und Konto, ausgehende Git-Aufrufe, WebAuthn-Optionen; Konto sperrt sich nach 8 Fehlversuchen für 15 Minuten |
|
||||||
|
| **Eingaben** | Zod-Schemas an jeder API-Grenze, Body auf 256 KB begrenzt |
|
||||||
|
| **Git-Tokens** | AES-256-GCM, Schlüssel per scrypt aus `APP_SECRET`. Werden nie an den Browser ausgeliefert — die API zeigt nur `ghp_…9f3a` |
|
||||||
|
| **Sitzungsende** | Deaktivieren, Löschen oder ein neues Passwort beenden sofort alle Sitzungen des Kontos |
|
||||||
|
|
||||||
|
Grenzen, die du kennen solltest:
|
||||||
|
|
||||||
|
- Ist `APP_SECRET` nicht gesetzt, warnt die App beim Start und nutzt einen unsicheren
|
||||||
|
Entwicklungsschlüssel.
|
||||||
|
- Ohne `Origin`- **und** `Sec-Fetch-Site`-Header wird eine schreibende Anfrage
|
||||||
|
durchgelassen. Das betrifft nur Nicht-Browser-Clients (curl, Skripte) und ist bewusst so,
|
||||||
|
damit die API skriptbar bleibt — ein CSRF-Vektor ist es nicht, weil jeder Browser
|
||||||
|
mindestens einen der beiden Header sendet.
|
||||||
|
- Das Rate-Limiting liegt im Arbeitsspeicher und wird beim Neustart zurückgesetzt.
|
||||||
|
- Es gibt **kein** HSTS und keine TLS-Terminierung. Für den Betrieb im Internet gehört ein
|
||||||
|
Reverse Proxy mit gültigem Zertifikat davor — der ist ohnehin nötig, damit Passkeys
|
||||||
|
funktionieren.
|
||||||
|
- Passwort-Zurücksetzen per E-Mail gibt es nicht; ein Administrator setzt Passwörter neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
| Symptom | Ursache / Lösung |
|
||||||
|
| --- | --- |
|
||||||
|
| `Can't reach database server` | Läuft `npm run docker:db`? Ein blosses `docker compose up -d db` startet die DB **ohne Host-Port** — dann ist sie von `npm run dev` aus nicht erreichbar. Stimmt `DATABASE_URL`? |
|
||||||
|
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
|
||||||
|
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
|
||||||
|
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
|
||||||
|
| Sync meldet `403 Rate-Limit` | Ohne Token ist das Limit sehr niedrig — Token hinterlegen |
|
||||||
|
| Tokens plötzlich ungültig | `APP_SECRET` wurde geändert — Tokens neu eintragen |
|
||||||
|
| Port 3000 belegt | `APP_PORT` in der `.env` ändern (Docker) bzw. `npm run dev -- -p 3001` |
|
||||||
|
| `package.json#prisma is deprecated` | Harmlose Warnung von Prisma 6.19. Die Umstellung auf `prisma.config.ts` steht aus, weil dann die `.env` nicht mehr automatisch geladen wird — vor Prisma 7 zu erledigen |
|
||||||
|
| Passkey-Knopf fehlt / „kein sicherer Kontext" | `APP_URL` zeigt weder auf `https://` noch auf `http://localhost` |
|
||||||
|
| Passkey wird nicht akzeptiert | `APP_URL` weicht von der aufgerufenen Adresse ab, oder der Hostname wurde geändert (RP-ID) |
|
||||||
|
| Nach `docker compose down -v` ist alles weg | Das Volume enthielt Konten und Projekte — `-v` löscht es |
|
||||||
|
| Kein Zugriff auf die eigene Gitea im LAN | Läuft sie auf demselben Rechner? Dann `GIT_ALLOW_LOOPBACK=true` setzen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getestet mit
|
||||||
|
|
||||||
|
Stand 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei
|
||||||
|
Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz).
|
||||||
|
|
||||||
|
| | Version |
|
||||||
|
| --- | --- |
|
||||||
|
| Node.js | 26.5.0 |
|
||||||
|
| npm | 11.17.0 |
|
||||||
|
| Next.js | 15.5.21 |
|
||||||
|
| Prisma | 6.19.3 |
|
||||||
|
| PostgreSQL | 17 (Alpine) |
|
||||||
|
| Docker / Compose | 29.6.2 / v5.3.1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT — siehe [LICENSE](LICENSE).
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
|
||||||
|
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
||||||
|
|
||||||
|
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
|
||||||
|
|
||||||
|
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Als Nächstes empfohlen
|
||||||
|
|
||||||
|
### 1.0 Rest aus v1.6.0: persönliche API-Token `M`
|
||||||
|
|
||||||
|
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0
|
||||||
|
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich
|
||||||
|
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per
|
||||||
|
Shortcut.
|
||||||
|
|
||||||
|
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende
|
||||||
|
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über
|
||||||
|
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
|
||||||
|
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
|
||||||
|
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
|
||||||
|
Migration wert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Läuft es noch? `M`
|
||||||
|
|
||||||
|
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
|
||||||
|
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
|
||||||
|
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
|
||||||
|
greift dabei wie bei den Git-Aufrufen.
|
||||||
|
|
||||||
|
### 1.3 Persönliche API-Token `M`
|
||||||
|
|
||||||
|
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
|
||||||
|
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
|
||||||
|
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den
|
||||||
|
Sitzungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M`
|
||||||
|
|
||||||
|
Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im
|
||||||
|
Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt
|
||||||
|
`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0
|
||||||
|
(`tasks/from-issues`); es fehlt die Gegenrichtung.
|
||||||
|
|
||||||
|
**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
|
||||||
|
Git-Operationen* — klonen, committen, pushen. Issues laufen über die
|
||||||
|
HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
|
||||||
|
SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
|
||||||
|
|
||||||
|
Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
|
||||||
|
Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
|
||||||
|
her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
|
||||||
|
Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
|
||||||
|
|
||||||
|
**Stufe 1 — eine Richtung, ausdrücklich** `M`
|
||||||
|
Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die
|
||||||
|
vorhandenen Felder. Kein Automatismus: was nach außen geht, wird
|
||||||
|
angestoßen, nicht nebenbei mitgeschrieben.
|
||||||
|
|
||||||
|
**Stufe 2 — Rückweg über Webhooks** `M`
|
||||||
|
Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist
|
||||||
|
„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen.
|
||||||
|
|
||||||
|
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
|
||||||
|
`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf
|
||||||
|
verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine
|
||||||
|
Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie
|
||||||
|
`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit
|
||||||
|
Zuständen zumüllen, die dort niemand pflegt.
|
||||||
|
|
||||||
|
**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht
|
||||||
|
vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für
|
||||||
|
ein persönliches Dashboard steht der Aufwand in keinem Verhältnis.
|
||||||
|
|
||||||
|
Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die
|
||||||
|
Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als
|
||||||
|
Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung
|
||||||
|
ein Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Größere Brocken
|
||||||
|
|
||||||
|
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
|
||||||
|
|
||||||
|
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
||||||
|
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
|
||||||
|
|
||||||
|
### 2.2 Mehrsprachigkeit `L`
|
||||||
|
|
||||||
|
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
|
||||||
|
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Kleinigkeiten
|
||||||
|
|
||||||
|
| Idee | Aufwand | Nutzen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
|
||||||
|
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
|
||||||
|
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
|
||||||
|
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
|
||||||
|
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab |
|
||||||
|
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
|
||||||
|
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
||||||
|
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
||||||
|
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Bewusst nicht
|
||||||
|
|
||||||
|
Damit klar ist, was **nicht** kommt und warum:
|
||||||
|
|
||||||
|
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
||||||
|
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
||||||
|
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
|
||||||
|
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
|
||||||
|
Dependabot machen das besser, und es wären viele Aufrufe an fremde
|
||||||
|
Registries — für eine Zahl, die im Repo selbst schon steht.
|
||||||
|
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
|
||||||
|
bei jeder Sicherung. Ein Link ins Git tut es auch.
|
||||||
|
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
|
||||||
|
Übersicht, keine Entwicklungsumgebung.
|
||||||
|
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
|
||||||
|
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
|
||||||
|
der Punkt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
|
||||||
|
| GitHub, GitLab und Gitea | ✓ |
|
||||||
|
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
|
||||||
|
| Einzel- und Mehrbenutzerbetrieb | ✓ |
|
||||||
|
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
||||||
|
| Docker- und Standalone-Betrieb | ✓ |
|
||||||
|
| Automatischer Abgleich mit Zeitplan | ✓ |
|
||||||
|
| Export, Import und Sicherung | ✓ |
|
||||||
|
| **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 | ✓ |
|
||||||
|
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
|
||||||
|
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
|
||||||
|
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
|
||||||
|
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
|
||||||
|
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
|
||||||
|
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
||||||
|
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
|
||||||
|
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
|
||||||
|
|
||||||
|
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
|
||||||
|
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
|
||||||
|
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Overlay fuer die Entwicklung: macht die Datenbank auf dem Host erreichbar.
|
||||||
|
#
|
||||||
|
# Gebraucht wird das nur, wenn die Anwendung ausserhalb des Containers
|
||||||
|
# laeuft – "npm run dev" verbindet sich dann ueber localhost:5432. Im
|
||||||
|
# Vollbetrieb spricht die Anwendung "db:5432" im Docker-Netz an und braucht
|
||||||
|
# diese Datei nicht.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
|
||||||
|
# npm run dev
|
||||||
|
#
|
||||||
|
# Kuerzer: npm run docker:db
|
||||||
|
#
|
||||||
|
# Gebunden wird auf 127.0.0.1, nicht auf 0.0.0.0. Docker traegt Freigaben
|
||||||
|
# direkt in iptables ein und umgeht dabei ufw – eine offene Bindung stuende
|
||||||
|
# also auch bei aktiver Firewall im Netz.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Overlay fuer den Serverbetrieb: kein einziger Port geht zum Host.
|
||||||
|
#
|
||||||
|
# Gedacht fuer einen Docker-Host, auf dem mehrere Stacks liegen und ein
|
||||||
|
# gemeinsamer Reverse Proxy die einzige Tuer nach draussen ist. Die
|
||||||
|
# Datenbank veroeffentlicht ohnehin nichts; diese Datei nimmt zusaetzlich
|
||||||
|
# die Freigabe der Anwendung zurueck und haengt sie in ein geteiltes Netz,
|
||||||
|
# ueber das der Proxy sie erreicht.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.server.yml \
|
||||||
|
# --profile full up -d --build
|
||||||
|
#
|
||||||
|
# Bequemer ist, das dauerhaft in die .env zu schreiben:
|
||||||
|
# COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
|
||||||
|
# COMPOSE_PROFILES=full
|
||||||
|
# Danach genuegt ueberall "docker compose up -d" – auch in update.sh.
|
||||||
|
# (Trennzeichen: Doppelpunkt unter Linux, Semikolon unter Windows.)
|
||||||
|
#
|
||||||
|
# Das Netz muss es vorher geben (der Proxy-Stack legt es ueblicherweise an):
|
||||||
|
# docker network create proxy
|
||||||
|
#
|
||||||
|
# Im Proxy zeigt das Ziel dann auf http://nebula:3000 bzw. nebula-app:3000.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# !reset loescht die Portfreigabe aus der Basisdatei. Ohne dieses Tag
|
||||||
|
# wuerden die Listen zusammengefuegt statt ersetzt – Compose behandelt
|
||||||
|
# "ports" beim Zusammenfuehren als anzuhaengende Liste. Braucht
|
||||||
|
# Compose 2.24 oder neuer.
|
||||||
|
ports: !reset []
|
||||||
|
networks:
|
||||||
|
# Das stackeigene Netz: darueber spricht die Anwendung mit der Datenbank.
|
||||||
|
default:
|
||||||
|
# Das geteilte Netz zum Reverse Proxy. Der Alias ist der Name, unter dem
|
||||||
|
# der Proxy Nebula anspricht – kurz und eindeutig, denn "app" hiessen in
|
||||||
|
# einem geteilten Netz schnell mehrere Container.
|
||||||
|
proxy:
|
||||||
|
aliases:
|
||||||
|
- ${PROXY_ALIAS:-nebula}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
name: ${PROXY_NETWORK:-proxy}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
name: nebula
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Diese Datei veroeffentlicht nur einen einzigen Port, und den auf dem
|
||||||
|
# Loopback: den der Anwendung. Die Datenbank bleibt im Docker-Netz.
|
||||||
|
#
|
||||||
|
# 1) Alles im Container (Server wie Entwicklung):
|
||||||
|
# docker compose --profile full up -d --build
|
||||||
|
#
|
||||||
|
# 2) Standalone-Entwicklung – Datenbank im Container, App auf dem Host.
|
||||||
|
# Dafuer muss Postgres auf dem Host erreichbar sein, also mit dem
|
||||||
|
# Entwicklungs-Overlay (oder kurz: npm run docker:db):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
|
||||||
|
# npm run dev
|
||||||
|
#
|
||||||
|
# 3) Server ohne jede Portfreigabe, hinter einem Reverse Proxy im
|
||||||
|
# gemeinsamen Docker-Netz: siehe docker-compose.server.yml
|
||||||
|
#
|
||||||
|
# Optionales DB-Webinterface:
|
||||||
|
# docker compose --profile tools up -d adminer → http://localhost:8080
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: nebula-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nebula}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nebula}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nebula}
|
||||||
|
# Deterministische Sortierung unabhaengig vom Host-Locale
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||||
|
# Absichtlich keine Portfreigabe: die Anwendung erreicht die Datenbank im
|
||||||
|
# Container ueber "db:5432", und backup.sh geht ueber "docker exec". Nach
|
||||||
|
# aussen muss hier nichts. Wer die Datenbank vom Host aus braucht – fuer
|
||||||
|
# "npm run dev" oder psql – nimmt docker-compose.dev.yml dazu.
|
||||||
|
volumes:
|
||||||
|
- nebula-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nebula} -d ${POSTGRES_DB:-nebula}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
app:
|
||||||
|
profiles: ["full"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: nebula-app:latest
|
||||||
|
container_name: nebula-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-nebula}:${POSTGRES_PASSWORD:-nebula}@db:5432/${POSTGRES_DB:-nebula}?schema=public
|
||||||
|
# Unbedingt in der .env ueberschreiben – verschluesselt die Git-Tokens.
|
||||||
|
APP_SECRET: ${APP_SECRET:-nebula-dev-secret-bitte-in-der-env-aendern}
|
||||||
|
# Name und oeffentliche Adresse. APP_URL bestimmt die WebAuthn-rpID –
|
||||||
|
# passt sie nicht zur aufgerufenen Adresse, scheitern Passkeys.
|
||||||
|
APP_NAME: ${APP_NAME:-Nebula}
|
||||||
|
APP_TAGLINE: ${APP_TAGLINE:-Vibe Coding}
|
||||||
|
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:-}
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
ports:
|
||||||
|
# Nur auf dem Loopback: nach draussen kommt man ueber den Reverse Proxy.
|
||||||
|
# Docker schreibt seine Freigaben an ufw vorbei direkt in iptables – eine
|
||||||
|
# Bindung auf 0.0.0.0 stuende also im Netz, auch bei aktiver Firewall.
|
||||||
|
# Ganz ohne Host-Port: docker-compose.server.yml.
|
||||||
|
- "${APP_PORT:-127.0.0.1:3000}:3000"
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
profiles: ["tools"]
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: nebula-adminer
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ADMINER_DEFAULT_SERVER: db
|
||||||
|
ADMINER_DESIGN: dracula
|
||||||
|
ports:
|
||||||
|
- "${ADMINER_PORT:-127.0.0.1:8080}:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nebula-db-data:
|
||||||
|
name: nebula-db-data
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Startet Nebula im Container: erst Migrationen, dann der Server.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PRISMA_CLI="/opt/prisma-cli/node_modules/prisma/build/index.js"
|
||||||
|
SCHEMA="/app/prisma/schema.prisma"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "[nebula] FEHLER: DATABASE_URL ist nicht gesetzt." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PRISMA_CLI" ]; then
|
||||||
|
echo "[nebula] FEHLER: Prisma-CLI nicht unter $PRISMA_CLI gefunden." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[nebula] Migrationen werden eingespielt…"
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
max_attempts=15
|
||||||
|
until node "$PRISMA_CLI" migrate deploy --schema "$SCHEMA"; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||||
|
echo "[nebula] FEHLER: Migration nach $max_attempts Versuchen fehlgeschlagen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[nebula] Datenbank noch nicht bereit – neuer Versuch in 3s ($attempt/$max_attempts)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[nebula] Migrationen ok. Server startet auf Port ${PORT:-3000}."
|
||||||
|
exec "$@"
|
||||||
+627
@@ -0,0 +1,627 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
|
||||||
|
* Authentifizierung, Validierung und Datenbank hindurch. Genau die Dinge,
|
||||||
|
* die Unit-Tests nicht erreichen.
|
||||||
|
*
|
||||||
|
* Die Tests bauen aufeinander auf und laufen in dieser Reihenfolge, weil sie
|
||||||
|
* sich eine Datenbank teilen (siehe playwright.config.ts, workers: 1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN = { username: "chefin", password: "ein-langes-testpasswort" };
|
||||||
|
const ZWEITER = { username: "zweiter", password: "noch-ein-langes-passwort" };
|
||||||
|
|
||||||
|
async function login(request: APIRequestContext, user: { username: string; password: string }) {
|
||||||
|
const response = await request.post("/api/auth/login", { data: user });
|
||||||
|
expect(response.ok(), `Anmeldung als ${user.username}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test("Einrichtung legt das erste Konto an und sperrt sich danach", async ({ request }) => {
|
||||||
|
const response = await request.post("/api/auth/setup", {
|
||||||
|
data: { ...ADMIN, mode: "MULTI_USER" },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.json()).user.role).toBe("ADMIN");
|
||||||
|
|
||||||
|
// Ein zweiter Aufruf darf kein weiteres Adminkonto erzeugen.
|
||||||
|
const wieder = await request.post("/api/auth/setup", {
|
||||||
|
data: { username: "eindringling", password: "ein-langes-testpasswort" },
|
||||||
|
});
|
||||||
|
expect(wieder.status()).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne Anmeldung ist nichts zu holen", async ({ playwright }) => {
|
||||||
|
const anonym = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL });
|
||||||
|
for (const pfad of ["/api/projects", "/api/connections", "/api/admin/users"]) {
|
||||||
|
expect((await anonym.get(pfad)).status(), pfad).toBe(401);
|
||||||
|
}
|
||||||
|
await anonym.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fremdes Origin wird abgewiesen", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
const response = await request.post("/api/projects", {
|
||||||
|
data: { name: "Von woanders" },
|
||||||
|
headers: { Origin: "https://boese.example" },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Content-Type wird erzwungen", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
const response = await request.post("/api/projects", {
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
data: JSON.stringify({ name: "Umgehung" }),
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(415);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SSRF: interne Adressen werden geblockt", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
for (const ziel of ["http://127.0.0.1:5432", "http://169.254.169.254", "http://0.0.0.0"]) {
|
||||||
|
const response = await request.post("/api/connections", {
|
||||||
|
data: { name: `ssrf-${ziel}`, provider: "GITEA", baseUrl: ziel },
|
||||||
|
});
|
||||||
|
expect(response.status(), ziel).toBe(422);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Datentrennung zwischen zwei Konten", async ({ request, playwright }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
const angelegt = await request.post("/api/projects", {
|
||||||
|
data: { name: "Geheimprojekt", status: "IN_DEVELOPMENT" },
|
||||||
|
});
|
||||||
|
expect(angelegt.status()).toBe(201);
|
||||||
|
const projektId = (await angelegt.json()).project.id;
|
||||||
|
|
||||||
|
const aufgabe = await request.post(`/api/projects/${projektId}/tasks`, {
|
||||||
|
data: { title: "Nicht fuer fremde Augen" },
|
||||||
|
});
|
||||||
|
expect(aufgabe.status()).toBe(201);
|
||||||
|
const aufgabenId = (await aufgabe.json()).task.id;
|
||||||
|
|
||||||
|
// Zweites Konto anlegen und in einem eigenen Kontext anmelden.
|
||||||
|
const neu = await request.post("/api/admin/users", {
|
||||||
|
data: { ...ZWEITER, role: "USER" },
|
||||||
|
});
|
||||||
|
expect(neu.status()).toBe(201);
|
||||||
|
|
||||||
|
const fremd = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
await login(fremd, ZWEITER);
|
||||||
|
|
||||||
|
expect((await (await fremd.get("/api/projects")).json()).projects).toHaveLength(0);
|
||||||
|
|
||||||
|
// Fremde IDs muessen 404 liefern, nicht 403 – sonst waere die Existenz
|
||||||
|
// des Datensatzes bestaetigt.
|
||||||
|
expect((await fremd.get(`/api/projects/${projektId}`)).status()).toBe(404);
|
||||||
|
expect(
|
||||||
|
(await fremd.patch(`/api/projects/${projektId}`, { data: { name: "uebernommen" } })).status(),
|
||||||
|
).toBe(404);
|
||||||
|
expect((await fremd.delete(`/api/projects/${projektId}`)).status()).toBe(404);
|
||||||
|
expect((await fremd.get(`/api/projects/${projektId}/tasks`)).status()).toBe(404);
|
||||||
|
expect((await fremd.patch(`/api/tasks/${aufgabenId}`, { data: { title: "x" } })).status()).toBe(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adminwege bleiben zu.
|
||||||
|
expect((await fremd.get("/api/admin/users")).status()).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await fremd.patch("/api/admin/settings", { data: { mode: "SINGLE_USER" } })).status(),
|
||||||
|
).toBe(403);
|
||||||
|
|
||||||
|
// Und das Original ist unveraendert.
|
||||||
|
const danach = await (await request.get(`/api/projects/${projektId}`)).json();
|
||||||
|
expect(danach.project.name).toBe("Geheimprojekt");
|
||||||
|
expect(danach.project.tasks).toHaveLength(1);
|
||||||
|
|
||||||
|
await fremd.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
const projekt = (
|
||||||
|
await (await request.post("/api/projects", { data: { name: "Aufgabenprojekt" } })).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
const titel = ["eins", "zwei", "drei"];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const title of titel) {
|
||||||
|
const response = await request.post(`/api/projects/${projekt.id}/tasks`, {
|
||||||
|
data: { title, status: "TODO" },
|
||||||
|
});
|
||||||
|
ids.push((await response.json()).task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Aufgaben haengen sich hinten an.
|
||||||
|
const angelegt = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(angelegt.map((task: { position: number }) => task.position)).toEqual([0, 1, 2]);
|
||||||
|
|
||||||
|
// Umdrehen.
|
||||||
|
const gedreht = [...ids].reverse();
|
||||||
|
const reorder = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
|
||||||
|
data: { columns: { TODO: gedreht } },
|
||||||
|
});
|
||||||
|
expect(reorder.ok()).toBeTruthy();
|
||||||
|
const nachher = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(nachher.map((task: { title: string }) => task.title)).toEqual(["drei", "zwei", "eins"]);
|
||||||
|
|
||||||
|
// Eine nach DONE – Fortschritt folgt, sobald eingeschaltet.
|
||||||
|
await request.patch(`/api/projects/${projekt.id}`, { data: { progressFromTasks: true } });
|
||||||
|
await request.patch(`/api/tasks/${ids[0]}`, { data: { status: "DONE" } });
|
||||||
|
|
||||||
|
const mitFortschritt = (
|
||||||
|
await (await request.get(`/api/projects/${projekt.id}`)).json()
|
||||||
|
).project;
|
||||||
|
expect(mitFortschritt.progress).toBe(33);
|
||||||
|
|
||||||
|
// Eine ID aus einem anderen Projekt darf nicht verschoben werden.
|
||||||
|
const anderes = (
|
||||||
|
await (await request.post("/api/projects", { data: { name: "Anderes" } })).json()
|
||||||
|
).project;
|
||||||
|
const fremdeAufgabe = (
|
||||||
|
await (
|
||||||
|
await request.post(`/api/projects/${anderes.id}/tasks`, { data: { title: "fremd" } })
|
||||||
|
).json()
|
||||||
|
).task;
|
||||||
|
|
||||||
|
const untergeschoben = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
|
||||||
|
data: { columns: { DOING: [fremdeAufgabe.id] } },
|
||||||
|
});
|
||||||
|
const ergebnis = await untergeschoben.json();
|
||||||
|
expect(ergebnis.updated).toBe(0);
|
||||||
|
expect(ergebnis.ignored).toBe(1);
|
||||||
|
|
||||||
|
const unveraendert = (
|
||||||
|
await (await request.get(`/api/projects/${anderes.id}/tasks`)).json()
|
||||||
|
).tasks[0];
|
||||||
|
expect(unveraendert.status).toBe("TODO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
const projekt = (
|
||||||
|
await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
// Ohne verknuepftes Repository gibt es nichts zu uebernehmen.
|
||||||
|
const ohneRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(ohneRepo.status()).toBe(409);
|
||||||
|
|
||||||
|
// Mit Repository, aber ohne geholten Stand ebenfalls nicht – der Server
|
||||||
|
// laedt hier bewusst nichts nach.
|
||||||
|
await request.patch(`/api/projects/${projekt.id}`, {
|
||||||
|
data: { repoUrl: "https://github.com/vercel/swr" },
|
||||||
|
});
|
||||||
|
const ohneStand = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(ohneStand.status()).toBe(409);
|
||||||
|
|
||||||
|
// Nach einem bestimmten, das es nicht gibt, ist dagegen 404 richtig.
|
||||||
|
const fremdesRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
|
||||||
|
data: { repositoryId: "gibt-es-nicht" },
|
||||||
|
});
|
||||||
|
expect(fremdesRepo.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
// Beim Anlegen setzen die Git-Felder das fuehrende Repository.
|
||||||
|
const projekt = (
|
||||||
|
await (
|
||||||
|
await request.post("/api/projects", {
|
||||||
|
data: { name: "Zwei Teile", repoUrl: "https://github.com/vercel/swr" },
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
expect(projekt.repositories).toHaveLength(1);
|
||||||
|
expect(projekt.repositories[0].repoOwner).toBe("vercel");
|
||||||
|
expect(projekt.repositories[0].repoName).toBe("swr");
|
||||||
|
|
||||||
|
// Ein zweites dazu.
|
||||||
|
const zweites = await request.post(`/api/projects/${projekt.id}/repositories`, {
|
||||||
|
data: { label: "Backend", repoUrl: "https://github.com/vercel/next.js" },
|
||||||
|
});
|
||||||
|
expect(zweites.status()).toBe(201);
|
||||||
|
const backendId = (await zweites.json()).repository.id;
|
||||||
|
|
||||||
|
// Dasselbe Repo zweimal ist immer ein Versehen.
|
||||||
|
const doppelt = await request.post(`/api/projects/${projekt.id}/repositories`, {
|
||||||
|
data: { repoUrl: "https://github.com/vercel/next.js" },
|
||||||
|
});
|
||||||
|
expect(doppelt.status()).toBe(409);
|
||||||
|
|
||||||
|
// Halbe Angaben ergeben kein Repository.
|
||||||
|
const leer = await request.post(`/api/projects/${projekt.id}/repositories`, {
|
||||||
|
data: { label: "Nur ein Name" },
|
||||||
|
});
|
||||||
|
expect(leer.status()).toBe(422);
|
||||||
|
|
||||||
|
const geladen = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
|
||||||
|
expect(geladen.repositories).toHaveLength(2);
|
||||||
|
// Das fuehrende steht vorn.
|
||||||
|
expect(geladen.repositories[0].repoName).toBe("swr");
|
||||||
|
expect(geladen.repositories[1].label).toBe("Backend");
|
||||||
|
|
||||||
|
// Der Projekt-PATCH bearbeitet weiterhin nur das fuehrende.
|
||||||
|
await request.patch(`/api/projects/${projekt.id}`, { data: { autoSync: false } });
|
||||||
|
const danach = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
|
||||||
|
expect(danach.repositories[0].autoSync).toBe(false);
|
||||||
|
expect(danach.repositories[1].autoSync).toBe(true);
|
||||||
|
|
||||||
|
// Fremde Konten kommen an kein Repository.
|
||||||
|
const fremd = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
await login(fremd, ZWEITER);
|
||||||
|
expect((await fremd.get(`/api/projects/${projekt.id}/repositories`)).status()).toBe(404);
|
||||||
|
expect((await fremd.patch(`/api/repositories/${backendId}`, { data: {} })).status()).toBe(404);
|
||||||
|
expect((await fremd.delete(`/api/repositories/${backendId}`)).status()).toBe(404);
|
||||||
|
expect((await fremd.post(`/api/repositories/${backendId}/sync`)).status()).toBe(404);
|
||||||
|
await fremd.dispose();
|
||||||
|
|
||||||
|
// Entfernen laesst das andere stehen.
|
||||||
|
expect((await request.delete(`/api/repositories/${backendId}`)).ok()).toBeTruthy();
|
||||||
|
const zumSchluss = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
|
||||||
|
expect(zumSchluss.repositories).toHaveLength(1);
|
||||||
|
expect(zumSchluss.repositories[0].repoName).toBe("swr");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
const angelegt = [];
|
||||||
|
for (const name of ["Brett A", "Brett B", "Brett C"]) {
|
||||||
|
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
|
||||||
|
angelegt.push((await response.json()).project);
|
||||||
|
}
|
||||||
|
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
|
||||||
|
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
|
||||||
|
// Spalte stehen schon Projekte aus frueheren Tests.
|
||||||
|
const [a, b, c] = angelegt.map((p) => p.position);
|
||||||
|
expect([b, c]).toEqual([a + 1, a + 2]);
|
||||||
|
|
||||||
|
const ids = angelegt.map((p) => p.id);
|
||||||
|
const vorher = angelegt[0].updatedAt;
|
||||||
|
|
||||||
|
// Umdrehen und dabei eines in eine andere Spalte schieben.
|
||||||
|
const gedreht = [ids[2], ids[1]];
|
||||||
|
const reorder = await request.patch("/api/projects/reorder", {
|
||||||
|
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
|
||||||
|
});
|
||||||
|
expect(reorder.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const alle = (await (await request.get("/api/projects")).json()).projects;
|
||||||
|
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
|
||||||
|
expect(finde(ids[2]).position).toBe(0);
|
||||||
|
expect(finde(ids[1]).position).toBe(1);
|
||||||
|
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
|
||||||
|
|
||||||
|
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
|
||||||
|
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
|
||||||
|
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
|
||||||
|
new Date(angelegt[1].updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
// Das verschobene dagegen hat sich wirklich geaendert.
|
||||||
|
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
|
||||||
|
new Date(vorher).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
|
||||||
|
const ergaenzt = await request.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
|
||||||
|
});
|
||||||
|
expect((await ergaenzt.json()).changed).toBe(3);
|
||||||
|
const mitTags = (await (await request.get("/api/projects")).json()).projects;
|
||||||
|
// Kleingeschrieben abgelegt, wie ueberall.
|
||||||
|
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
|
||||||
|
|
||||||
|
const entfernt = await request.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
|
||||||
|
});
|
||||||
|
expect((await entfernt.json()).changed).toBe(3);
|
||||||
|
|
||||||
|
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
|
||||||
|
// uebereinander auf derselben Position.
|
||||||
|
const verschoben = await request.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "status", status: "PLANNING" },
|
||||||
|
});
|
||||||
|
expect((await verschoben.json()).changed).toBe(3);
|
||||||
|
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
|
||||||
|
const positionen = geplant.map((p: { position: number }) => p.position).sort();
|
||||||
|
expect(new Set(positionen).size).toBe(positionen.length);
|
||||||
|
|
||||||
|
// Wer schon so steht, wird nicht noch einmal angefasst.
|
||||||
|
const nochmal = await request.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "status", status: "PLANNING" },
|
||||||
|
});
|
||||||
|
expect((await nochmal.json()).changed).toBe(0);
|
||||||
|
|
||||||
|
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
|
||||||
|
const fremd = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
await login(fremd, ZWEITER);
|
||||||
|
const untergeschoben = await fremd.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "delete" },
|
||||||
|
});
|
||||||
|
expect((await untergeschoben.json()).changed).toBe(0);
|
||||||
|
const uebrig = (await (await request.get("/api/projects")).json()).projects;
|
||||||
|
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
|
||||||
|
|
||||||
|
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
|
||||||
|
data: { columns: { DONE: ids } },
|
||||||
|
});
|
||||||
|
expect((await fremdSortieren.json()).updated).toBe(0);
|
||||||
|
await fremd.dispose();
|
||||||
|
|
||||||
|
// Und zum Schluss die eigenen wirklich loeschen.
|
||||||
|
const geloescht = await request.post("/api/projects/bulk", {
|
||||||
|
data: { ids, action: "delete" },
|
||||||
|
});
|
||||||
|
expect((await geloescht.json()).changed).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
const projekt = (
|
||||||
|
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
const woechentlich = (
|
||||||
|
await (
|
||||||
|
await request.post(`/api/projects/${projekt.id}/tasks`, {
|
||||||
|
data: {
|
||||||
|
title: "Abhaengigkeiten pruefen",
|
||||||
|
dueDate: "2026-07-06T12:00:00",
|
||||||
|
recurrence: "WEEKLY",
|
||||||
|
labels: ["wartung"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).task;
|
||||||
|
|
||||||
|
// Ein Zwischenschritt loest noch nichts aus.
|
||||||
|
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
|
||||||
|
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(alle).toHaveLength(1);
|
||||||
|
|
||||||
|
// Erst der Uebergang nach ERLEDIGT.
|
||||||
|
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
|
||||||
|
data: { status: "DONE" },
|
||||||
|
});
|
||||||
|
expect((await erledigt.json()).next).not.toBeNull();
|
||||||
|
|
||||||
|
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(alle).toHaveLength(2);
|
||||||
|
|
||||||
|
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
|
||||||
|
expect(folge.status).toBe("TODO");
|
||||||
|
expect(folge.title).toBe("Abhaengigkeiten pruefen");
|
||||||
|
expect(folge.labels).toEqual(["wartung"]);
|
||||||
|
expect(folge.recurrence).toBe("WEEKLY");
|
||||||
|
expect(folge.recurredFrom).toBe(woechentlich.id);
|
||||||
|
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
|
||||||
|
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
|
||||||
|
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
|
||||||
|
|
||||||
|
// Erneut speichern erzeugt keine dritte.
|
||||||
|
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
|
||||||
|
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(alle).toHaveLength(2);
|
||||||
|
|
||||||
|
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
|
||||||
|
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
|
||||||
|
data: { columns: { DONE: [folge.id] } },
|
||||||
|
});
|
||||||
|
expect(gezogen.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(alle).toHaveLength(3);
|
||||||
|
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
|
||||||
|
|
||||||
|
// Eine einmalige Aufgabe bleibt einmalig.
|
||||||
|
const einmalig = (
|
||||||
|
await (
|
||||||
|
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
|
||||||
|
).json()
|
||||||
|
).task;
|
||||||
|
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
|
||||||
|
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
|
||||||
|
expect(alle).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
// Ein Projekt herrichten, das als Vorlage taugt.
|
||||||
|
const quelle = (
|
||||||
|
await (
|
||||||
|
await request.post("/api/projects", {
|
||||||
|
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
await request.post(`/api/projects/${quelle.id}/notes`, {
|
||||||
|
data: { content: "Checkliste vor dem Start." },
|
||||||
|
});
|
||||||
|
const erledigt = (
|
||||||
|
await (
|
||||||
|
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
|
||||||
|
).json()
|
||||||
|
).task;
|
||||||
|
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
|
||||||
|
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
|
||||||
|
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
|
||||||
|
|
||||||
|
const gesichert = await request.post("/api/templates/from-project", {
|
||||||
|
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
|
||||||
|
});
|
||||||
|
expect(gesichert.status()).toBe(201);
|
||||||
|
const vorlage = (await gesichert.json()).template;
|
||||||
|
|
||||||
|
expect(vorlage.tags).toEqual(["web", "docker"]);
|
||||||
|
expect(vorlage.status).toBe("PLANNING");
|
||||||
|
expect(vorlage.notes).toHaveLength(1);
|
||||||
|
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
|
||||||
|
expect(vorlage.tasks).toHaveLength(2);
|
||||||
|
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
|
||||||
|
|
||||||
|
// Derselbe Name ein zweites Mal wird abgewiesen.
|
||||||
|
const doppelt = await request.post("/api/templates/from-project", {
|
||||||
|
data: { projectId: quelle.id, name: "Web-App" },
|
||||||
|
});
|
||||||
|
expect(doppelt.status()).toBe(409);
|
||||||
|
|
||||||
|
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
|
||||||
|
const neu = (
|
||||||
|
await (
|
||||||
|
await request.post("/api/projects", {
|
||||||
|
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).project;
|
||||||
|
|
||||||
|
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
|
||||||
|
expect(geladen.notes).toHaveLength(1);
|
||||||
|
expect(geladen.tasks).toHaveLength(2);
|
||||||
|
// Die Reihenfolge der Vorlage bleibt erhalten.
|
||||||
|
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
|
||||||
|
"Repo anlegen",
|
||||||
|
"CI einrichten",
|
||||||
|
]);
|
||||||
|
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
|
||||||
|
expect(geladen.status).toBe("IDEA");
|
||||||
|
|
||||||
|
// Der Verlauf haelt die Herkunft fest.
|
||||||
|
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
|
||||||
|
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
|
||||||
|
|
||||||
|
// Eine fremde Vorlage laesst sich nicht unterschieben.
|
||||||
|
const fremd = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
await login(fremd, ZWEITER);
|
||||||
|
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
|
||||||
|
const untergeschoben = await fremd.post("/api/projects", {
|
||||||
|
data: { name: "Klau", templateId: vorlage.id },
|
||||||
|
});
|
||||||
|
expect(untergeschoben.status()).toBe(404);
|
||||||
|
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
|
||||||
|
await fremd.dispose();
|
||||||
|
|
||||||
|
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
|
||||||
|
await request.delete(`/api/projects/${quelle.id}`);
|
||||||
|
const nochDa = (await (await request.get("/api/templates")).json()).templates;
|
||||||
|
expect(nochDa).toHaveLength(1);
|
||||||
|
|
||||||
|
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
|
||||||
|
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
|
||||||
|
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
|
||||||
|
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
|
||||||
|
const anonym = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
maxRedirects: 0,
|
||||||
|
});
|
||||||
|
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
|
||||||
|
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
|
||||||
|
await anonym.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
await request.post("/api/connections", {
|
||||||
|
data: {
|
||||||
|
name: "Mit Token",
|
||||||
|
provider: "GITEA",
|
||||||
|
baseUrl: "https://gitea.example.com",
|
||||||
|
token: "streng-geheimes-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const roh = await (await request.get("/api/projects/export")).text();
|
||||||
|
expect(roh).not.toContain("streng-geheimes-token");
|
||||||
|
expect(roh).not.toContain("tokenCipher");
|
||||||
|
expect(roh).not.toContain("ownerId");
|
||||||
|
|
||||||
|
const datei = JSON.parse(roh);
|
||||||
|
const vorher = datei.projects.length;
|
||||||
|
|
||||||
|
// Version 2: die Repositories stehen als Liste in der Datei.
|
||||||
|
expect(datei.version).toBe(2);
|
||||||
|
const mitRepo = datei.projects.find((entry: { name: string }) => entry.name === "Zwei Teile");
|
||||||
|
expect(mitRepo.repositories).toHaveLength(1);
|
||||||
|
expect(mitRepo.repositories[0].repoName).toBe("swr");
|
||||||
|
|
||||||
|
// Ueberspringen aendert nichts.
|
||||||
|
const uebersprungen = await request.post("/api/projects/import", {
|
||||||
|
data: { data: datei, strategy: "skip", dryRun: false },
|
||||||
|
});
|
||||||
|
expect((await uebersprungen.json()).summary.skipped).toBe(vorher);
|
||||||
|
|
||||||
|
// Zusaetzlich anlegen verdoppelt.
|
||||||
|
const zusaetzlich = await request.post("/api/projects/import", {
|
||||||
|
data: { data: datei, strategy: "rename", dryRun: false },
|
||||||
|
});
|
||||||
|
expect((await zusaetzlich.json()).summary.renamed).toBe(vorher);
|
||||||
|
|
||||||
|
const jetzt = (await (await request.get("/api/projects")).json()).projects;
|
||||||
|
expect(jetzt).toHaveLength(vorher * 2);
|
||||||
|
|
||||||
|
// Muell wird abgewiesen.
|
||||||
|
const muell = await request.post("/api/projects/import", {
|
||||||
|
data: { data: { irgendwas: true }, strategy: "skip", dryRun: true },
|
||||||
|
});
|
||||||
|
expect(muell.status()).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dateien der Version 1 bleiben lesbar", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
// Damals stand genau ein Repository flach am Projekt. Wer eine solche
|
||||||
|
// Sicherung liegen hat, soll sie weiterhin einspielen koennen.
|
||||||
|
const alt = {
|
||||||
|
kind: "nebula.projects",
|
||||||
|
version: 1,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "Aus Version eins",
|
||||||
|
repoUrl: "https://github.com/vercel/swr",
|
||||||
|
repoOwner: "vercel",
|
||||||
|
repoName: "swr",
|
||||||
|
autoSync: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const gelesen = await request.post("/api/projects/import", {
|
||||||
|
data: { data: alt, strategy: "rename", dryRun: false },
|
||||||
|
});
|
||||||
|
expect(gelesen.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const projekte = (await (await request.get("/api/projects")).json()).projects;
|
||||||
|
const uebernommen = projekte.find(
|
||||||
|
(entry: { name: string }) => entry.name === "Aus Version eins",
|
||||||
|
);
|
||||||
|
expect(uebernommen.repositories).toHaveLength(1);
|
||||||
|
expect(uebernommen.repositories[0].repoOwner).toBe("vercel");
|
||||||
|
expect(uebernommen.repositories[0].autoSync).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet die Testdatenbank vor: Schema einspielen und leeren.
|
||||||
|
*
|
||||||
|
* Die Datenbank muss vorher existieren. Anlegen ist bewusst nicht Teil des
|
||||||
|
* Setups – in CI erzeugt sie der Postgres-Dienst, oertlich einmalig:
|
||||||
|
*
|
||||||
|
* docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DATABASE_URL =
|
||||||
|
process.env.E2E_DATABASE_URL ??
|
||||||
|
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reihenfolge egal, weil CASCADE – aber vollstaendig muss sie sein, sonst
|
||||||
|
* schleppen Laeufe Reste mit.
|
||||||
|
*/
|
||||||
|
const TABLES = [
|
||||||
|
"Task",
|
||||||
|
"Note",
|
||||||
|
"RepoSnapshot",
|
||||||
|
"Project",
|
||||||
|
"Credential",
|
||||||
|
"Session",
|
||||||
|
"WebAuthnChallenge",
|
||||||
|
"GitConnection",
|
||||||
|
"User",
|
||||||
|
"AppSettings",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const name = new URL(DATABASE_URL).pathname.replace(/^\//, "");
|
||||||
|
|
||||||
|
// Schutz vor dem teuersten aller Fehler: eine Testreihe, die die echte
|
||||||
|
// Datenbank leert. Der Name muss "test" enthalten, sonst passiert nichts.
|
||||||
|
if (!name.includes("test")) {
|
||||||
|
throw new Error(
|
||||||
|
`Die Testdatenbank heisst "${name}" und enthaelt nicht "test". ` +
|
||||||
|
"Aus Sicherheitsgruenden abgebrochen – E2E_DATABASE_URL pruefen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[e2e] Datenbank: ${name}`);
|
||||||
|
|
||||||
|
execFileSync("npx", ["prisma", "migrate", "deploy"], {
|
||||||
|
env: { ...process.env, DATABASE_URL },
|
||||||
|
stdio: "pipe",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } });
|
||||||
|
try {
|
||||||
|
const list = TABLES.map((table) => `"${table}"`).join(", ");
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`);
|
||||||
|
console.log("[e2e] Datenbank geleert.");
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Weg durch die Oberflaeche, wie ihn ein Mensch geht: anmelden, Projekt
|
||||||
|
* anlegen, Aufgaben verwalten. Keine Vollabdeckung – die Wege, die kaputt
|
||||||
|
* gehen duerfen, ohne dass es jemand merkt.
|
||||||
|
*
|
||||||
|
* Setzt voraus, dass api.spec.ts das Konto bereits angelegt hat; laeuft
|
||||||
|
* alphabetisch danach.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KONTO = { username: "chefin", password: "ein-langes-testpasswort" };
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill("#login-username", KONTO.username);
|
||||||
|
await page.fill("#login-password", KONTO.password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.getByText("Vibe Coding Kontrollzentrum")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dashboard zeigt Kennzahlen und Projekte", async ({ page }) => {
|
||||||
|
await expect(page.getByRole("heading", { name: /Deine Projekte/ })).toBeVisible();
|
||||||
|
await expect(page.getByText("Projekte", { exact: false }).first()).toBeVisible();
|
||||||
|
// Keine unbeabsichtigten Fehler in der Konsole.
|
||||||
|
expect(page.url()).toContain("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Projekt anlegen und wiederfinden", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: /Neues Projekt/ }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
|
||||||
|
await page.fill("#project-name", "Oberflaechentest");
|
||||||
|
await page.fill("#project-summary", "Von Playwright angelegt.");
|
||||||
|
await page.getByRole("button", { name: "Projekt anlegen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog")).toBeHidden();
|
||||||
|
await expect(page.getByText("Oberflaechentest")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
|
||||||
|
|
||||||
|
// Zwei Aufgaben ueber den Dialog anlegen.
|
||||||
|
for (const titel of ["Erste Aufgabe", "Zweite Aufgabe"]) {
|
||||||
|
await page.getByRole("button", { name: "Aufgabe", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await page.fill("#task-title", titel);
|
||||||
|
await page.getByRole("button", { name: "Anlegen" }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
const offen = page.locator('[data-task-column="TODO"]');
|
||||||
|
const inArbeit = page.locator('[data-task-column="DOING"]');
|
||||||
|
await expect(offen.getByText("Erste Aufgabe")).toBeVisible();
|
||||||
|
await expect(offen.getByText("Zweite Aufgabe")).toBeVisible();
|
||||||
|
|
||||||
|
// Erste Aufgabe nach "In Arbeit" ziehen.
|
||||||
|
const griff = offen.locator('button[aria-label$="verschieben"]').first();
|
||||||
|
const von = await griff.boundingBox();
|
||||||
|
const nach = await inArbeit.boundingBox();
|
||||||
|
expect(von && nach).toBeTruthy();
|
||||||
|
|
||||||
|
await page.mouse.move(von!.x + von!.width / 2, von!.y + von!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
// Erst ueber die Aktivierungsschwelle des Sensors (5 Pixel) hinaus.
|
||||||
|
await page.mouse.move(von!.x + 30, von!.y + 10, { steps: 6 });
|
||||||
|
await page.mouse.move(nach!.x + nach!.width / 2, nach!.y + 60, { steps: 20 });
|
||||||
|
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
|
||||||
|
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
|
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
|
||||||
|
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
|
||||||
|
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
|
||||||
|
const gespeichert = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
|
||||||
|
expect((await gespeichert).ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// Und der Server hat es auch so gespeichert.
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-task-column="DOING"]').getByText("Erste Aufgabe"),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Volltextsuche findet Notizen", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Notiz", exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder(/Gedanken/).fill("Suchbegriff Zaunkoenig steht hier.");
|
||||||
|
await page.getByRole("button", { name: "Speichern" }).click();
|
||||||
|
await expect(page.getByText("Zaunkoenig", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
// exact, sonst trifft es auch "Zurueck zum Dashboard".
|
||||||
|
await page.getByRole("link", { name: "Dashboard", exact: true }).click();
|
||||||
|
await page.getByPlaceholder(/durchsuchen/).fill("Zaunkoenig");
|
||||||
|
|
||||||
|
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Liste" }).click();
|
||||||
|
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
|
||||||
|
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
|
||||||
|
// Seite liess sich seitwaerts schieben.
|
||||||
|
const masse = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
|
||||||
|
|
||||||
|
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
|
||||||
|
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
|
||||||
|
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
|
||||||
|
const knopf = page.getByRole("button", { name: "Neues Projekt" });
|
||||||
|
await expect(knopf).toBeVisible();
|
||||||
|
const kasten = await knopf.boundingBox();
|
||||||
|
expect(kasten).not.toBeNull();
|
||||||
|
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Schnell erfassen" }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas.
|
||||||
|
await page.fill("#quick-title", "Aus der Bahn notiert");
|
||||||
|
await dialog.getByRole("button", { name: "Anlegen" }).click();
|
||||||
|
|
||||||
|
// Der Dialog bleibt offen und bestaetigt – das ist der Punkt der
|
||||||
|
// Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen.
|
||||||
|
await expect(dialog.getByText("Angelegt")).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible();
|
||||||
|
await expect(page.locator("#quick-title")).toHaveValue("");
|
||||||
|
|
||||||
|
// Aufgabe: derselbe Dialog, ein Feld mehr.
|
||||||
|
await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click();
|
||||||
|
await page.fill("#quick-title", "Unterwegs eingefallen");
|
||||||
|
await expect(page.locator("#quick-project")).toBeVisible();
|
||||||
|
await dialog.getByRole("button", { name: "Anlegen" }).click();
|
||||||
|
await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
// Die Idee steht als Projekt im Dashboard.
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("Aus der Bahn notiert")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
|
||||||
|
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
|
||||||
|
|
||||||
|
// Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf,
|
||||||
|
// obwohl sie in einem anderen Projekt haengt.
|
||||||
|
const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" });
|
||||||
|
await expect(zeile).toBeVisible();
|
||||||
|
|
||||||
|
// Abhaken aus der Uebersicht heraus.
|
||||||
|
await zeile.getByRole("button", { name: /abhaken/ }).click();
|
||||||
|
// Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste.
|
||||||
|
await expect(zeile).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByLabel("Erledigte einblenden").check();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
||||||
|
await page.keyboard.press("Control+k");
|
||||||
|
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
||||||
|
|
||||||
|
// Ohne Eingabe stehen Projekte und Bereiche bereit.
|
||||||
|
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig");
|
||||||
|
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
|
||||||
|
|
||||||
|
// Enter springt zum ersten Treffer.
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
|
||||||
|
|
||||||
|
// Escape schliesst wieder.
|
||||||
|
await page.keyboard.press("Control+k");
|
||||||
|
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
|
||||||
|
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
|
||||||
|
const anzahl = await kaestchen.count();
|
||||||
|
expect(anzahl).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
|
||||||
|
await expect(page.getByText(/ausgewählt/)).toBeHidden();
|
||||||
|
|
||||||
|
await kaestchen.first().check();
|
||||||
|
await expect(page.getByText("1 ausgewählt")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Tags", exact: true }).click();
|
||||||
|
await page.fill("#bulk-tags", "sammeltest");
|
||||||
|
await page.getByRole("button", { name: "Hinzufügen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("#sammeltest").first()).toBeVisible();
|
||||||
|
// Nach getaner Arbeit ist die Auswahl aufgehoben.
|
||||||
|
await expect(page.getByText(/ausgewählt/)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
|
||||||
|
|
||||||
|
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
|
||||||
|
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
// Die laufende Fassung ist als solche gekennzeichnet.
|
||||||
|
await expect(dialog.getByText("installiert")).toBeVisible();
|
||||||
|
// Und es steht wirklich etwas drin.
|
||||||
|
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Neu").first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
|
||||||
|
const anonym = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
|
||||||
|
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
|
||||||
|
const antwort = await anonym.get("/manifest.webmanifest");
|
||||||
|
expect(antwort.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const manifest = await antwort.json();
|
||||||
|
expect(manifest.name).toContain("Nebula");
|
||||||
|
expect(manifest.display).toBe("standalone");
|
||||||
|
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
|
||||||
|
|
||||||
|
for (const icon of manifest.icons) {
|
||||||
|
const bild = await anonym.get(icon.src);
|
||||||
|
expect(bild.ok(), icon.src).toBeTruthy();
|
||||||
|
expect(bild.headers()["content-type"]).toContain("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
await anonym.dispose();
|
||||||
|
|
||||||
|
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
|
||||||
|
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
|
||||||
|
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: /Benutzermenue/ }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Abmelden" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
|
// Und die geschuetzte Seite ist wieder zu.
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+16
-6
@@ -7,13 +7,23 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
experimental: {
|
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
|
||||||
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
|
|
||||||
serverActions: {
|
|
||||||
bodySizeLimit: "2mb",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
serverExternalPackages: ["@prisma/client", "prisma"],
|
serverExternalPackages: ["@prisma/client", "prisma"],
|
||||||
|
images: {
|
||||||
|
/**
|
||||||
|
* Der Bildoptimierer ist abgeschaltet.
|
||||||
|
*
|
||||||
|
* Nebula benutzt next/image nirgends – die paar Symbole sind SVG und
|
||||||
|
* PNG in fester Groesse. Der Endpunkt /_next/image waere aber trotzdem
|
||||||
|
* da, und zwar ohne Anmeldung: die Middleware nimmt ihn aus, weil dort
|
||||||
|
* sonst nur Rechenzeit verbraucht wuerde.
|
||||||
|
*
|
||||||
|
* Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
|
||||||
|
* regelmaessig Schwachstellen gibt (aktuell CVE-2026-33327 und drei
|
||||||
|
* weitere). Eine Angriffsflaeche ohne Gegenwert – also weg damit.
|
||||||
|
*/
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+5839
File diff suppressed because it is too large
Load Diff
+25
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.1.0",
|
"version": "1.6.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": {
|
||||||
@@ -16,35 +16,53 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "prisma migrate reset",
|
||||||
"docker:db": "docker compose up -d db",
|
"docker:db": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db",
|
||||||
"docker:db:down": "docker compose stop db",
|
"docker:db:down": "docker compose stop db",
|
||||||
"docker:up": "docker compose --profile full up -d --build",
|
"docker:up": "docker compose --profile full up -d --build",
|
||||||
"docker:down": "docker compose --profile full down",
|
"docker:down": "docker compose --profile full down",
|
||||||
"docker:logs": "docker compose --profile full logs -f app",
|
"docker:logs": "docker compose --profile full logs -f app",
|
||||||
"docker:tools": "docker compose --profile tools up -d",
|
"docker:tools": "docker compose --profile tools up -d",
|
||||||
"setup": "node scripts/setup.mjs",
|
"setup": "node scripts/setup.mjs",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
},
|
"test": "vitest run",
|
||||||
"prisma": {
|
"test:watch": "vitest",
|
||||||
"seed": "tsx prisma/seed.ts"
|
"test:e2e": "npm run build && playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:all": "npm test && npm run test:e2e"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@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",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^3.25.28"
|
"zod": "^3.25.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@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/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"axe-core": "^4.12.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.11"
|
"node": ">=20.11"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ende-zu-Ende-Tests gegen die echte Anwendung.
|
||||||
|
*
|
||||||
|
* Playwright startet den Produktionsserver selbst und richtet ihn auf eine
|
||||||
|
* eigene Datenbank. Der Port ist bewusst ein anderer als 3000 und 3100,
|
||||||
|
* damit eine laufende Instanz nicht stoert.
|
||||||
|
*
|
||||||
|
* Voraussetzung: ein Bau (`npm run build`) und eine leere Datenbank, deren
|
||||||
|
* Name "test" enthaelt – siehe global-setup.ts. Beides erledigt
|
||||||
|
* `npm run test:e2e`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = Number(process.env.E2E_PORT ?? 3111);
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
const DATABASE_URL =
|
||||||
|
process.env.E2E_DATABASE_URL ??
|
||||||
|
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
// Die Tests teilen sich eine Datenbank, deshalb nacheinander.
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
// Oertlich der installierte Chrome, damit kein Browser geladen werden
|
||||||
|
// muss. In CI gibt es keinen – dort das mitgelieferte Chromium
|
||||||
|
// (siehe "playwright install chromium" im Arbeitsablauf).
|
||||||
|
...(process.env.CI ? {} : { channel: "chrome" as const }),
|
||||||
|
},
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: "npm start",
|
||||||
|
url: `${BASE_URL}/api/health`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
DATABASE_URL,
|
||||||
|
PORT: String(PORT),
|
||||||
|
APP_URL: BASE_URL,
|
||||||
|
NODE_ENV: "production",
|
||||||
|
// Fester Schluessel, damit Laeufe vergleichbar bleiben.
|
||||||
|
APP_SECRET: "e2e-test-secret-nur-fuer-die-testlaeufe-1234567890",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { config as loadEnv } from "dotenv";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguration der Prisma-CLI.
|
||||||
|
*
|
||||||
|
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
|
||||||
|
* wegfaellt.
|
||||||
|
*
|
||||||
|
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
|
||||||
|
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
|
||||||
|
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
|
||||||
|
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
|
||||||
|
* dann .env.local: eine oertliche Abweichung soll gewinnen.
|
||||||
|
*/
|
||||||
|
loadEnv({ path: ".env", quiet: true });
|
||||||
|
loadEnv({ path: ".env.local", override: true, quiet: true });
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProjectStatus" AS ENUM ('IDEA', 'OPEN', 'PLANNING', 'IN_DEVELOPMENT', 'DONE', 'ARCHIVED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GitProvider" AS ENUM ('GITHUB', 'GITLAB', 'GITEA');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GitConnection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"provider" "GitProvider" NOT NULL,
|
||||||
|
"baseUrl" TEXT NOT NULL,
|
||||||
|
"username" TEXT,
|
||||||
|
"tokenCipher" TEXT,
|
||||||
|
"tokenHint" TEXT,
|
||||||
|
"lastCheckedAt" TIMESTAMP(3),
|
||||||
|
"lastCheckOk" BOOLEAN,
|
||||||
|
"lastCheckMessage" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GitConnection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"summary" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 2,
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"accent" TEXT NOT NULL DEFAULT 'violet',
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"favorite" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"repoUrl" TEXT,
|
||||||
|
"repoOwner" TEXT,
|
||||||
|
"repoName" TEXT,
|
||||||
|
"defaultBranch" TEXT,
|
||||||
|
"connectionId" TEXT,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3),
|
||||||
|
"syncError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Note" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"title" TEXT,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RepoSnapshot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"defaultBranch" TEXT,
|
||||||
|
"htmlUrl" TEXT,
|
||||||
|
"visibility" TEXT,
|
||||||
|
"stars" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"forks" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"openIssues" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"openPullRequests" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sizeKb" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastCommitSha" TEXT,
|
||||||
|
"lastCommitMessage" TEXT,
|
||||||
|
"lastCommitAuthor" TEXT,
|
||||||
|
"lastCommitAt" TIMESTAMP(3),
|
||||||
|
"latestTag" TEXT,
|
||||||
|
"latestRelease" TEXT,
|
||||||
|
"latestReleaseAt" TIMESTAMP(3),
|
||||||
|
"branchCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"tagCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"branches" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"tags" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"commits" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"releases" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"languages" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"pushedAt" TIMESTAMP(3),
|
||||||
|
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RepoSnapshot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GitConnection_name_key" ON "GitConnection"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GitConnection_provider_idx" ON "GitConnection"("provider");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_status_idx" ON "Project"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_connectionId_idx" ON "Project"("connectionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_updatedAt_idx" ON "Project"("updatedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Note_projectId_idx" ON "Note"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Note_pinned_idx" ON "Note"("pinned");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RepoSnapshot_projectId_key" ON "RepoSnapshot"("projectId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Note" ADD CONSTRAINT "Note_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "GitConnection_name_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Project_slug_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lockedUntil" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"ip" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Credential" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"credentialId" TEXT NOT NULL,
|
||||||
|
"publicKey" BYTEA NOT NULL,
|
||||||
|
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"deviceType" TEXT,
|
||||||
|
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"name" TEXT,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WebAuthnChallenge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"challenge" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"purpose" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AppSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
|
||||||
|
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
|
||||||
|
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastAutoSyncReport" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Volltextsuche ueber Notizen.
|
||||||
|
--
|
||||||
|
-- Dies ist ein Ausdrucksindex. Prisma kann so etwas im Schema nicht abbilden
|
||||||
|
-- und kennt ihn deshalb nicht. Nachgeprueft: "prisma migrate diff" laesst ihn
|
||||||
|
-- in Ruhe und schlaegt kein DROP vor - Ausdrucksindizes werden schlicht
|
||||||
|
-- ignoriert. Trotzdem beim Erzeugen kuenftiger Migrationen kurz hinsehen.
|
||||||
|
--
|
||||||
|
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
|
||||||
|
-- Postgres berechnet to_tsvector dann je Zeile.
|
||||||
|
--
|
||||||
|
-- Der Ausdruck muss mit dem in src/app/api/search/route.ts uebereinstimmen,
|
||||||
|
-- sonst greift der Index nicht. Die zweistellige Form von to_tsvector mit
|
||||||
|
-- fester Konfiguration ist IMMUTABLE und damit indizierbar.
|
||||||
|
|
||||||
|
CREATE INDEX "Note_fulltext_idx" ON "Note"
|
||||||
|
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || "content"));
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "ciBranch" TEXT,
|
||||||
|
ADD COLUMN "ciFinishedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "ciName" TEXT,
|
||||||
|
ADD COLUMN "ciState" TEXT,
|
||||||
|
ADD COLUMN "ciUrl" TEXT,
|
||||||
|
ADD COLUMN "commitActivity" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
ADD COLUMN "issues" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
ADD COLUMN "pullRequests" JSONB NOT NULL DEFAULT '[]';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Task" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"labels" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"doneAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceIssue" INTEGER,
|
||||||
|
ADD COLUMN "sourceUrl" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Task_projectId_sourceIssue_key" ON "Task"("projectId", "sourceIssue");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Activity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"kind" "ActivityKind" NOT NULL,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"meta" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "totpSecretCipher" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RecoveryCode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RecoveryCode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PendingLogin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PendingLogin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RecoveryCode_codeHash_key" ON "RecoveryCode"("codeHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RecoveryCode_userId_idx" ON "RecoveryCode"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PendingLogin_tokenHash_key" ON "PendingLogin"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PendingLogin_userId_idx" ON "PendingLogin"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PendingLogin_expiresAt_idx" ON "PendingLogin"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RecoveryCode" ADD CONSTRAINT "RecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PendingLogin" ADD CONSTRAINT "PendingLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- Mehrere Repositories je Projekt.
|
||||||
|
--
|
||||||
|
-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende
|
||||||
|
-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht;
|
||||||
|
-- der Snapshot haengt danach am Repository statt am Projekt.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt
|
||||||
|
-- ersatzlos loeschen und die Snapshots gleich mit.
|
||||||
|
|
||||||
|
-- ── 1. Neue Tabelle ─────────────────────────────────────────
|
||||||
|
CREATE TABLE "Repository" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"repoUrl" TEXT,
|
||||||
|
"repoOwner" TEXT,
|
||||||
|
"repoName" TEXT,
|
||||||
|
"defaultBranch" TEXT,
|
||||||
|
"connectionId" TEXT,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3),
|
||||||
|
"syncError" TEXT,
|
||||||
|
"autoSync" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncFailureCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"notifiedSyncError" TEXT,
|
||||||
|
"notifiedCiState" TEXT,
|
||||||
|
"notifiedRelease" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Repository_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position");
|
||||||
|
CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId");
|
||||||
|
|
||||||
|
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey"
|
||||||
|
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey"
|
||||||
|
FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ── 2. Bestand uebernehmen ──────────────────────────────────
|
||||||
|
-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine
|
||||||
|
-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine
|
||||||
|
-- leere Zeile.
|
||||||
|
INSERT INTO "Repository" (
|
||||||
|
"id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch",
|
||||||
|
"connectionId", "position", "lastSyncedAt", "syncError", "autoSync",
|
||||||
|
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch",
|
||||||
|
"connectionId", 0, "lastSyncedAt", "syncError", "autoSync",
|
||||||
|
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
|
||||||
|
"createdAt", CURRENT_TIMESTAMP
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "repoUrl" IS NOT NULL
|
||||||
|
OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL);
|
||||||
|
|
||||||
|
-- ── 3. Snapshots umhaengen ──────────────────────────────────
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT;
|
||||||
|
|
||||||
|
UPDATE "RepoSnapshot" AS s
|
||||||
|
SET "repositoryId" = r."id"
|
||||||
|
FROM "Repository" AS r
|
||||||
|
WHERE r."projectId" = s."projectId";
|
||||||
|
|
||||||
|
-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn
|
||||||
|
-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht
|
||||||
|
-- mehr zuzuordnen und beim naechsten Abgleich neu geholt.
|
||||||
|
DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL;
|
||||||
|
ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey";
|
||||||
|
DROP INDEX "RepoSnapshot_projectId_key";
|
||||||
|
ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId");
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey"
|
||||||
|
FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ── 4. Herkunft uebernommener Aufgaben ──────────────────────
|
||||||
|
-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos
|
||||||
|
-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5.
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT;
|
||||||
|
|
||||||
|
UPDATE "Task" AS t
|
||||||
|
SET "sourceRepositoryId" = r."id"
|
||||||
|
FROM "Repository" AS r
|
||||||
|
WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL;
|
||||||
|
|
||||||
|
DROP INDEX "Task_projectId_sourceIssue_key";
|
||||||
|
CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key"
|
||||||
|
ON "Task"("projectId", "sourceRepositoryId", "sourceIssue");
|
||||||
|
|
||||||
|
-- ── 5. Projekt aufraeumen ───────────────────────────────────
|
||||||
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey";
|
||||||
|
DROP INDEX "Project_connectionId_idx";
|
||||||
|
|
||||||
|
ALTER TABLE "Project"
|
||||||
|
DROP COLUMN "repoUrl",
|
||||||
|
DROP COLUMN "repoOwner",
|
||||||
|
DROP COLUMN "repoName",
|
||||||
|
DROP COLUMN "defaultBranch",
|
||||||
|
DROP COLUMN "connectionId",
|
||||||
|
DROP COLUMN "lastSyncedAt",
|
||||||
|
DROP COLUMN "syncError",
|
||||||
|
DROP COLUMN "autoSync",
|
||||||
|
DROP COLUMN "syncFailureCount",
|
||||||
|
DROP COLUMN "notifiedSyncError",
|
||||||
|
DROP COLUMN "notifiedCiState",
|
||||||
|
DROP COLUMN "notifiedRelease";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Freie Reihenfolge im Projektbrett.
|
||||||
|
--
|
||||||
|
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
|
||||||
|
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
|
||||||
|
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
|
||||||
|
-- jedem Laden anders ergeben koennte.
|
||||||
|
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE "Project" AS p
|
||||||
|
SET "position" = nummeriert.rang - 1
|
||||||
|
FROM (
|
||||||
|
SELECT "id", ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
|
||||||
|
) AS rang
|
||||||
|
FROM "Project"
|
||||||
|
) AS nummeriert
|
||||||
|
WHERE nummeriert."id" = p."id";
|
||||||
|
|
||||||
|
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"summary" TEXT,
|
||||||
|
"projectDescription" TEXT,
|
||||||
|
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 2,
|
||||||
|
"accent" TEXT NOT NULL DEFAULT 'violet',
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"progressFromTasks" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notes" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"tasks" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT,
|
||||||
|
ADD COLUMN "recurrence" "Recurrence";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
|
||||||
|
ADD COLUMN "readmeAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "readmeBaseUrl" TEXT,
|
||||||
|
ADD COLUMN "readmePath" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Nebula – Datenmodell
|
||||||
|
// Datenbank: PostgreSQL (lokal oder via docker compose)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
// linux-musl-openssl-3.0.x wird fuer das Alpine-Docker-Image benoetigt.
|
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lebenszyklus eines Projekts. IDEA ist der Startzustand fuer
|
||||||
|
/// frisch notierte Einfaelle, ARCHIVED blendet Projekte aus dem
|
||||||
|
/// Standard-Board aus, ohne sie zu loeschen.
|
||||||
|
enum ProjectStatus {
|
||||||
|
IDEA
|
||||||
|
OPEN
|
||||||
|
PLANNING
|
||||||
|
IN_DEVELOPMENT
|
||||||
|
DONE
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unterstuetzte Git-Systeme.
|
||||||
|
enum GitProvider {
|
||||||
|
GITHUB
|
||||||
|
GITLAB
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
|
||||||
|
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
|
||||||
|
/// mitschreiben.
|
||||||
|
enum ActivityKind {
|
||||||
|
PROJECT_CREATED
|
||||||
|
PROJECT_UPDATED
|
||||||
|
STATUS_CHANGED
|
||||||
|
NOTE_ADDED
|
||||||
|
NOTE_UPDATED
|
||||||
|
NOTE_DELETED
|
||||||
|
TASK_ADDED
|
||||||
|
TASK_MOVED
|
||||||
|
TASK_DELETED
|
||||||
|
TASKS_IMPORTED
|
||||||
|
REPO_LINKED
|
||||||
|
REPO_COMMITS
|
||||||
|
SYNC_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
|
||||||
|
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
|
||||||
|
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
|
||||||
|
enum Recurrence {
|
||||||
|
DAILY
|
||||||
|
WEEKLY
|
||||||
|
BIWEEKLY
|
||||||
|
MONTHLY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
|
||||||
|
enum TaskStatus {
|
||||||
|
TODO
|
||||||
|
DOING
|
||||||
|
BLOCKED
|
||||||
|
DONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
|
||||||
|
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
|
||||||
|
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
|
||||||
|
enum AppMode {
|
||||||
|
SINGLE_USER
|
||||||
|
MULTI_USER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
|
||||||
|
/// ausschliesslich per Passkey nutzbar sein darf.
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
|
||||||
|
/// gross-/kleinschreibungsunabhaengig funktioniert.
|
||||||
|
username String @unique
|
||||||
|
displayName String?
|
||||||
|
email String? @unique
|
||||||
|
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
|
||||||
|
passwordHash String?
|
||||||
|
role UserRole @default(USER)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
/// Zaehler und Sperre gegen Passwortraten.
|
||||||
|
failedLoginCount Int @default(0)
|
||||||
|
lockedUntil DateTime?
|
||||||
|
|
||||||
|
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
|
||||||
|
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
|
||||||
|
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
|
||||||
|
/// sperrt sich aus, wer die Einrichtung abbricht.
|
||||||
|
totpSecretCipher String?
|
||||||
|
totpEnabledAt DateTime?
|
||||||
|
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
|
||||||
|
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
|
||||||
|
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
|
||||||
|
totpLastCounter Int?
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
credentials Credential[]
|
||||||
|
projects Project[]
|
||||||
|
connections GitConnection[]
|
||||||
|
notificationTargets NotificationTarget[]
|
||||||
|
activities Activity[]
|
||||||
|
recoveryCodes RecoveryCode[]
|
||||||
|
pendingLogins PendingLogin[]
|
||||||
|
templates ProjectTemplate[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
|
||||||
|
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
userAgent String?
|
||||||
|
ip String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein registrierter Passkey (WebAuthn-Credential).
|
||||||
|
model Credential {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
/// base64url-kodierte Credential-ID des Authenticators.
|
||||||
|
credentialId String @unique
|
||||||
|
publicKey Bytes
|
||||||
|
counter Int @default(0)
|
||||||
|
transports String[] @default([])
|
||||||
|
deviceType String?
|
||||||
|
backedUp Boolean @default(false)
|
||||||
|
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
|
||||||
|
name String?
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
|
||||||
|
/// Jeder Code gilt genau einmal.
|
||||||
|
model RecoveryCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
|
||||||
|
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
|
||||||
|
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
|
||||||
|
codeHash String @unique
|
||||||
|
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
|
||||||
|
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
|
||||||
|
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
|
||||||
|
model PendingLogin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
|
||||||
|
tokenHash String @unique
|
||||||
|
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
|
||||||
|
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
|
||||||
|
attempts Int @default(0)
|
||||||
|
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
|
||||||
|
/// kein zweites Mal verwendet werden kann.
|
||||||
|
model WebAuthnChallenge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
challenge String @unique
|
||||||
|
/// null bei der Anmeldung ueber auffindbare Passkeys.
|
||||||
|
userId String?
|
||||||
|
/// "registration" oder "authentication"
|
||||||
|
purpose String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
|
||||||
|
model AppSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
mode AppMode @default(SINGLE_USER)
|
||||||
|
/// Nur im Mehrbenutzerbetrieb wirksam.
|
||||||
|
allowRegistration Boolean @default(false)
|
||||||
|
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||||
|
rpName String @default("Nebula")
|
||||||
|
|
||||||
|
// ── Automatischer Abgleich mit den Git-Systemen ────────────
|
||||||
|
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
|
||||||
|
/// das soll eine bewusste Entscheidung sein.
|
||||||
|
autoSyncEnabled Boolean @default(false)
|
||||||
|
/// Abstand zwischen zwei Durchlaeufen in Minuten.
|
||||||
|
autoSyncIntervalMinutes Int @default(60)
|
||||||
|
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
|
||||||
|
/// noch etwas.
|
||||||
|
autoSyncActiveOnly Boolean @default(true)
|
||||||
|
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
|
||||||
|
/// einen Schlag in ein Rate-Limit laeuft.
|
||||||
|
autoSyncBatchSize Int @default(25)
|
||||||
|
|
||||||
|
lastAutoSyncAt DateTime?
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||||
|
model GitConnection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
provider GitProvider
|
||||||
|
/// Basis-URL der Instanz, z. B. https://github.com, https://gitlab.com,
|
||||||
|
/// https://git.meine-domain.de
|
||||||
|
baseUrl String
|
||||||
|
/// Optionaler Benutzername – rein informativ fuer die Anzeige.
|
||||||
|
username String?
|
||||||
|
|
||||||
|
/// Personal Access Token, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
|
||||||
|
tokenCipher String?
|
||||||
|
/// Gekuerzte Darstellung des Tokens fuer die UI, z. B. "ghp_…4f2a".
|
||||||
|
tokenHint String?
|
||||||
|
|
||||||
|
lastCheckedAt DateTime?
|
||||||
|
lastCheckOk Boolean?
|
||||||
|
lastCheckMessage String?
|
||||||
|
|
||||||
|
/// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der
|
||||||
|
/// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten
|
||||||
|
/// Kontos werden sie diesem zugeordnet.
|
||||||
|
ownerId String?
|
||||||
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
repositories Repository[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, name])
|
||||||
|
@@index([provider])
|
||||||
|
@@index([ownerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
/// Pro Besitzer eindeutig – siehe @@unique unten.
|
||||||
|
slug String
|
||||||
|
/// Einzeiler fuer die Karte im Dashboard.
|
||||||
|
summary String?
|
||||||
|
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
|
||||||
|
description String?
|
||||||
|
status ProjectStatus @default(IDEA)
|
||||||
|
/// 1 = niedrig, 2 = normal, 3 = hoch, 4 = kritisch
|
||||||
|
priority Int @default(2)
|
||||||
|
/// Fortschritt in Prozent (0–100)
|
||||||
|
progress Int @default(0)
|
||||||
|
/// Akzentfarbe der Karte: violet | cyan | emerald | amber | rose | blue
|
||||||
|
accent String @default("violet")
|
||||||
|
tags String[] @default([])
|
||||||
|
favorite Boolean @default(false)
|
||||||
|
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
|
||||||
|
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
|
||||||
|
position Int @default(0)
|
||||||
|
|
||||||
|
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||||
|
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||||
|
ownerId String?
|
||||||
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
|
||||||
|
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
|
||||||
|
progressFromTasks Boolean @default(false)
|
||||||
|
|
||||||
|
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
|
||||||
|
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
|
||||||
|
/// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht
|
||||||
|
/// ueber ein einzelnes Repository.
|
||||||
|
notifiedStaleAt DateTime?
|
||||||
|
|
||||||
|
notes Note[]
|
||||||
|
tasks Task[]
|
||||||
|
activities Activity[]
|
||||||
|
repositories Repository[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, slug])
|
||||||
|
@@index([status])
|
||||||
|
@@index([updatedAt])
|
||||||
|
@@index([ownerId])
|
||||||
|
@@index([ownerId, status, position])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
|
||||||
|
/// Frontend und Backend etwa –, gehoert aber in eine Karte.
|
||||||
|
///
|
||||||
|
/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen
|
||||||
|
/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist.
|
||||||
|
model Repository {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name
|
||||||
|
/// steht fuer sich.
|
||||||
|
label String?
|
||||||
|
|
||||||
|
repoUrl String?
|
||||||
|
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
|
||||||
|
repoOwner String?
|
||||||
|
repoName String?
|
||||||
|
defaultBranch String?
|
||||||
|
|
||||||
|
connectionId String?
|
||||||
|
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Reihenfolge in der Anzeige.
|
||||||
|
position Int @default(0)
|
||||||
|
|
||||||
|
lastSyncedAt DateTime?
|
||||||
|
syncError String?
|
||||||
|
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
||||||
|
/// einem Repo, dessen Token staendig Fehler wirft.
|
||||||
|
autoSync Boolean @default(true)
|
||||||
|
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
|
||||||
|
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
||||||
|
syncFailureCount Int @default(0)
|
||||||
|
|
||||||
|
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
|
||||||
|
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
|
||||||
|
/// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
|
||||||
|
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
|
||||||
|
notifiedSyncError String?
|
||||||
|
notifiedCiState String?
|
||||||
|
notifiedRelease String?
|
||||||
|
|
||||||
|
snapshot RepoSnapshot?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([projectId, position])
|
||||||
|
@@index([connectionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
|
||||||
|
description String?
|
||||||
|
status TaskStatus @default(TODO)
|
||||||
|
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
|
||||||
|
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
|
||||||
|
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
|
||||||
|
position Int @default(0)
|
||||||
|
dueDate DateTime?
|
||||||
|
labels String[] @default([])
|
||||||
|
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
|
||||||
|
doneAt DateTime?
|
||||||
|
|
||||||
|
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
|
||||||
|
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
|
||||||
|
/// bleibt stehen – sonst ginge die Historie verloren.
|
||||||
|
recurrence Recurrence?
|
||||||
|
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
|
||||||
|
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
|
||||||
|
/// Nachfolgerin nicht mitnimmt.
|
||||||
|
recurredFrom String?
|
||||||
|
|
||||||
|
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
|
||||||
|
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
|
||||||
|
sourceIssue Int?
|
||||||
|
sourceUrl String?
|
||||||
|
/// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und
|
||||||
|
/// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
|
||||||
|
sourceRepositoryId String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
|
||||||
|
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
|
||||||
|
@@unique([projectId, sourceRepositoryId, sourceIssue])
|
||||||
|
@@index([projectId, status, position])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
|
||||||
|
///
|
||||||
|
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
|
||||||
|
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
|
||||||
|
/// mitgelieferte Liste waere geraten.
|
||||||
|
model ProjectTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerId String
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
|
||||||
|
description String?
|
||||||
|
|
||||||
|
// ── Was die Vorlage in das neue Projekt schreibt ───────────
|
||||||
|
summary String?
|
||||||
|
projectDescription String?
|
||||||
|
status ProjectStatus @default(IDEA)
|
||||||
|
priority Int @default(2)
|
||||||
|
accent String @default("violet")
|
||||||
|
tags String[] @default([])
|
||||||
|
progressFromTasks Boolean @default(false)
|
||||||
|
|
||||||
|
/// [{ title, content, pinned }]
|
||||||
|
notes Json @default("[]")
|
||||||
|
/// [{ title, description, status, labels }]
|
||||||
|
tasks Json @default("[]")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, name])
|
||||||
|
@@index([ownerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
|
||||||
|
model Activity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
|
||||||
|
/// automatischen Abgleich.
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
kind ActivityKind
|
||||||
|
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
|
||||||
|
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
|
||||||
|
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
|
||||||
|
summary String
|
||||||
|
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
|
||||||
|
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
|
||||||
|
meta Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// Absteigend gelesen, immer je Projekt.
|
||||||
|
@@index([projectId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freie Notizen zu einem Projekt.
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
title String?
|
||||||
|
content String
|
||||||
|
pinned Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([pinned])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwischengespeicherter Stand eines Repositories. Wird beim Sync
|
||||||
|
/// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
|
||||||
|
model RepoSnapshot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
repositoryId String @unique
|
||||||
|
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
fullName String?
|
||||||
|
description String?
|
||||||
|
defaultBranch String?
|
||||||
|
htmlUrl String?
|
||||||
|
visibility String?
|
||||||
|
|
||||||
|
stars Int @default(0)
|
||||||
|
forks Int @default(0)
|
||||||
|
openIssues Int @default(0)
|
||||||
|
openPullRequests Int @default(0)
|
||||||
|
sizeKb Int @default(0)
|
||||||
|
|
||||||
|
lastCommitSha String?
|
||||||
|
lastCommitMessage String?
|
||||||
|
lastCommitAuthor String?
|
||||||
|
lastCommitAt DateTime?
|
||||||
|
|
||||||
|
latestTag String?
|
||||||
|
latestRelease String?
|
||||||
|
latestReleaseAt DateTime?
|
||||||
|
|
||||||
|
branchCount Int @default(0)
|
||||||
|
tagCount Int @default(0)
|
||||||
|
|
||||||
|
/// Rohdaten der Listen – bewusst als JSON, damit providerspezifische
|
||||||
|
/// Zusatzfelder nicht verloren gehen.
|
||||||
|
branches Json @default("[]")
|
||||||
|
tags Json @default("[]")
|
||||||
|
commits Json @default("[]")
|
||||||
|
releases Json @default("[]")
|
||||||
|
languages Json @default("{}")
|
||||||
|
pullRequests Json @default("[]")
|
||||||
|
issues Json @default("[]")
|
||||||
|
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
|
||||||
|
commitActivity Json @default("[]")
|
||||||
|
|
||||||
|
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
|
||||||
|
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
|
||||||
|
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
|
||||||
|
/// und deshalb hoechstens einmal am Tag geschieht.
|
||||||
|
commitCalendar Json @default("{}")
|
||||||
|
commitCalendarAt DateTime?
|
||||||
|
|
||||||
|
/// Das README des Repositories, roh als Markdown. Wie der Kalender
|
||||||
|
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
|
||||||
|
/// Abgleich laeuft im Zweifel stuendlich.
|
||||||
|
readme String?
|
||||||
|
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
|
||||||
|
readmePath String?
|
||||||
|
/// Wogegen relative Links im README aufzuloesen sind.
|
||||||
|
readmeBaseUrl String?
|
||||||
|
readmeAt DateTime?
|
||||||
|
|
||||||
|
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
|
||||||
|
/// success | failure | running | pending | cancelled
|
||||||
|
ciState String?
|
||||||
|
ciName String?
|
||||||
|
ciBranch String?
|
||||||
|
ciUrl String?
|
||||||
|
ciFinishedAt DateTime?
|
||||||
|
|
||||||
|
pushedAt DateTime?
|
||||||
|
fetchedAt DateTime @default(now())
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import { PrismaClient, ProjectStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface SampleNote {
|
||||||
|
title?: string | null;
|
||||||
|
content: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SampleProject {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
priority: number;
|
||||||
|
progress: number;
|
||||||
|
accent: string;
|
||||||
|
tags: string[];
|
||||||
|
favorite: boolean;
|
||||||
|
notes: SampleNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein paar Beispielprojekte an, damit das Dashboard nach dem ersten
|
||||||
|
* Start nicht leer ist. Idempotent: bereits vorhandene Slugs werden
|
||||||
|
* uebersprungen.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const samples: SampleProject[] = [
|
||||||
|
{
|
||||||
|
name: "Nebula",
|
||||||
|
slug: "nebula",
|
||||||
|
summary: "Dieses Dashboard – Uebersicht ueber alle Vibe-Coding Projekte.",
|
||||||
|
description:
|
||||||
|
"Meta: Nebula verwaltet sich selbst. Notizen, Status und Git-Infos an einem Ort.",
|
||||||
|
status: ProjectStatus.IN_DEVELOPMENT,
|
||||||
|
priority: 3,
|
||||||
|
progress: 65,
|
||||||
|
accent: "violet",
|
||||||
|
tags: ["next.js", "prisma", "dashboard"],
|
||||||
|
favorite: true,
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
title: "Naechste Schritte",
|
||||||
|
content:
|
||||||
|
"- Kanban-Ansicht ergaenzen\n- Webhook-Endpoint fuer Push-Events\n- Export als Markdown",
|
||||||
|
pinned: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Synthwave CLI",
|
||||||
|
slug: "synthwave-cli",
|
||||||
|
summary: "Terminal-Tool, das Projektgeruest inkl. CI in Sekunden erzeugt.",
|
||||||
|
description:
|
||||||
|
"Idee: ein `npx synthwave init` das Repo, Lint, Tests, Container und Pipeline vorkonfiguriert.",
|
||||||
|
status: ProjectStatus.PLANNING,
|
||||||
|
priority: 2,
|
||||||
|
progress: 15,
|
||||||
|
accent: "cyan",
|
||||||
|
tags: ["cli", "typescript", "dx"],
|
||||||
|
favorite: false,
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
title: null,
|
||||||
|
content: "Konkurrenz pruefen: create-t3-app, degit, plop.",
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pixelgarten",
|
||||||
|
slug: "pixelgarten",
|
||||||
|
summary: "Browser-Spiel: ein Garten, der aus Commit-Aktivitaet waechst.",
|
||||||
|
description:
|
||||||
|
"Rohe Idee. Jeder Commit pflanzt eine Pflanze, Streaks lassen sie bluehen.",
|
||||||
|
status: ProjectStatus.IDEA,
|
||||||
|
priority: 1,
|
||||||
|
progress: 0,
|
||||||
|
accent: "emerald",
|
||||||
|
tags: ["spiel", "canvas", "fun"],
|
||||||
|
favorite: false,
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Der Slug ist seit der Benutzerverwaltung nur je Besitzer eindeutig.
|
||||||
|
// Die Seed-Projekte entstehen ohne Besitzer und werden beim Einrichten
|
||||||
|
// des ersten Kontos uebernommen.
|
||||||
|
for (const sample of samples) {
|
||||||
|
const existing = await prisma.project.findFirst({
|
||||||
|
where: { slug: sample.slug, ownerId: null },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
console.log(` · uebersprungen (existiert): ${sample.slug}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notes, ...project } = sample;
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
...project,
|
||||||
|
notes: notes.length ? { create: notes } : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✓ angelegt: ${sample.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
console.log("Seed abgeschlossen.");
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error("Seed fehlgeschlagen:", error);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||||
|
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
|
||||||
|
man zum Wiederherstellen einer kaputten Instanz braucht.
|
||||||
|
|
||||||
|
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
|
||||||
|
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
|
||||||
|
|
||||||
|
.PARAMETER OutDir
|
||||||
|
Zielverzeichnis. Vorgabe: .\backups
|
||||||
|
|
||||||
|
.PARAMETER Container
|
||||||
|
Name des Postgres-Containers. Vorgabe: nebula-db
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\backup.ps1
|
||||||
|
.\scripts\backup.ps1 -OutDir D:\Sicherungen
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
|
||||||
|
[string]$Container = "nebula-db"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
|
||||||
|
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||||
|
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||||
|
if (Test-Path $envFile) {
|
||||||
|
foreach ($line in Get-Content $envFile) {
|
||||||
|
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||||
|
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $settings["POSTGRES_USER"]
|
||||||
|
$database = $settings["POSTGRES_DB"]
|
||||||
|
|
||||||
|
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||||
|
|
||||||
|
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
|
||||||
|
$target = Join-Path $OutDir "nebula-$stamp.sql"
|
||||||
|
|
||||||
|
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||||
|
if ($running -notcontains $Container) {
|
||||||
|
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
|
||||||
|
|
||||||
|
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||||
|
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
|
||||||
|
|
||||||
|
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
|
||||||
|
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
|
||||||
|
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||||
|
#
|
||||||
|
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||||
|
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
|
||||||
|
#
|
||||||
|
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
out_dir="${1:-$root/backups}"
|
||||||
|
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||||
|
|
||||||
|
user="nebula"
|
||||||
|
database="nebula"
|
||||||
|
if [ -f "$root/.env" ]; then
|
||||||
|
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
|
||||||
|
value_of() {
|
||||||
|
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||||
|
"$root/.env" | tail -n 1
|
||||||
|
}
|
||||||
|
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||||
|
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||||
|
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
stamp="$(date +%Y-%m-%d-%H%M%S)"
|
||||||
|
target="$out_dir/nebula-$stamp.sql"
|
||||||
|
|
||||||
|
echo "Sichere Datenbank '$database' aus Container '$container'..."
|
||||||
|
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||||
|
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
|
||||||
|
|
||||||
|
size="$(du -h "$target" | cut -f1)"
|
||||||
|
echo "Fertig: $target ($size)"
|
||||||
|
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
|
||||||
|
fragt vorher nach, es sei denn, -Force ist gesetzt.
|
||||||
|
|
||||||
|
.PARAMETER Database
|
||||||
|
Zieldatenbank. Vorgabe: der Wert aus der .env.
|
||||||
|
|
||||||
|
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
|
||||||
|
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
|
||||||
|
|
||||||
|
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||||
|
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$File,
|
||||||
|
[string]$Database,
|
||||||
|
[string]$Container = "nebula-db",
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
|
||||||
|
|
||||||
|
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||||
|
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||||
|
if (Test-Path $envFile) {
|
||||||
|
foreach ($line in Get-Content $envFile) {
|
||||||
|
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||||
|
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $settings["POSTGRES_USER"]
|
||||||
|
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
|
||||||
|
|
||||||
|
if (-not $Force) {
|
||||||
|
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
|
||||||
|
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
|
||||||
|
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||||
|
if ($running -notcontains $Container) {
|
||||||
|
Write-Error "Container '$Container' laeuft nicht."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Spiele '$File' ein..."
|
||||||
|
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
|
||||||
|
|
||||||
|
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
|
||||||
|
#
|
||||||
|
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
|
||||||
|
#
|
||||||
|
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
|
||||||
|
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
|
||||||
|
#
|
||||||
|
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||||
|
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
file="${1:-}"
|
||||||
|
force="${2:-}"
|
||||||
|
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||||
|
|
||||||
|
if [ -z "$file" ]; then
|
||||||
|
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "Datei nicht gefunden: $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
user="nebula"
|
||||||
|
database="nebula"
|
||||||
|
if [ -f "$root/.env" ]; then
|
||||||
|
value_of() {
|
||||||
|
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||||
|
"$root/.env" | tail -n 1
|
||||||
|
}
|
||||||
|
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||||
|
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||||
|
fi
|
||||||
|
database="${NEBULA_DB_NAME:-$database}"
|
||||||
|
|
||||||
|
if [ "$force" != "--force" ]; then
|
||||||
|
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
|
||||||
|
read -r answer
|
||||||
|
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||||
|
echo "Container '$container' laeuft nicht." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Spiele '$file' ein..."
|
||||||
|
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
|
||||||
|
|
||||||
|
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Nebula – Ersteinrichtung.
|
||||||
|
*
|
||||||
|
* Legt eine .env auf Basis von .env.example an und erzeugt dabei einen
|
||||||
|
* zufaelligen APP_SECRET. Vorhandene .env-Dateien werden nicht angefasst.
|
||||||
|
*
|
||||||
|
* node scripts/setup.mjs bzw. npm run setup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const envPath = join(root, ".env");
|
||||||
|
const examplePath = join(root, ".env.example");
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
reset: "[0m",
|
||||||
|
dim: "[2m",
|
||||||
|
green: "[32m",
|
||||||
|
yellow: "[33m",
|
||||||
|
cyan: "[36m",
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(examplePath)) {
|
||||||
|
console.error(`${c.yellow}.env.example nicht gefunden – abgebrochen.${c.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
log(`${c.yellow}·${c.reset} .env existiert bereits – sie wird nicht ueberschrieben.`);
|
||||||
|
} else {
|
||||||
|
const secret = randomBytes(32).toString("base64");
|
||||||
|
const content = readFileSync(examplePath, "utf8").replace(
|
||||||
|
/^APP_SECRET=.*$/m,
|
||||||
|
`APP_SECRET="${secret}"`,
|
||||||
|
);
|
||||||
|
writeFileSync(envPath, content, "utf8");
|
||||||
|
log(`${c.green}✓${c.reset} .env angelegt (APP_SECRET wurde zufaellig erzeugt).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("");
|
||||||
|
log(`${c.cyan}Naechste Schritte${c.reset}`);
|
||||||
|
log(`${c.dim}─────────────────────────────────────────────${c.reset}`);
|
||||||
|
log("Standalone (App lokal, Datenbank im Container):");
|
||||||
|
log(` ${c.dim}1.${c.reset} docker compose up -d db`);
|
||||||
|
log(` ${c.dim}2.${c.reset} npm install`);
|
||||||
|
log(` ${c.dim}3.${c.reset} npm run db:migrate`);
|
||||||
|
log(` ${c.dim}4.${c.reset} npm run db:seed ${c.dim}(optional, Beispieldaten)${c.reset}`);
|
||||||
|
log(` ${c.dim}5.${c.reset} npm run dev ${c.dim}→ http://localhost:3000${c.reset}`);
|
||||||
|
log("");
|
||||||
|
log("Alles in Docker:");
|
||||||
|
log(` ${c.dim}1.${c.reset} docker compose --profile full up -d --build`);
|
||||||
|
log(` ${c.dim}2.${c.reset} ${c.dim}→ http://localhost:3000${c.reset}`);
|
||||||
|
log("");
|
||||||
|
log("Eigene, bereits laufende PostgreSQL:");
|
||||||
|
log(` DATABASE_URL in der .env anpassen, dann npm run db:migrate`);
|
||||||
|
log("");
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Legt die .env an und wuerfelt ein APP_SECRET.
|
||||||
|
#
|
||||||
|
# Dasselbe wie scripts/setup.mjs, aber ohne Node: auf einem reinen
|
||||||
|
# Docker-Host ist keins installiert, und nur zum Erzeugen einer Textdatei
|
||||||
|
# eines zu verlangen waere albern. Braucht nichts ausser einer POSIX-Shell.
|
||||||
|
#
|
||||||
|
# Aufruf: ./scripts/setup.sh
|
||||||
|
# ./scripts/setup.sh --force (vorhandene .env ueberschreiben)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
env_datei="$root/.env"
|
||||||
|
vorlage="$root/.env.example"
|
||||||
|
force=0
|
||||||
|
|
||||||
|
[ "${1:-}" = "--force" ] && force=1
|
||||||
|
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
gruen=$'\033[32m'; gelb=$'\033[33m'; cyan=$'\033[36m'; dim=$'\033[2m'; aus=$'\033[0m'
|
||||||
|
else
|
||||||
|
gruen=""; gelb=""; cyan=""; dim=""; aus=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$vorlage" ] || { echo "${gelb}.env.example nicht gefunden – abgebrochen.${aus}" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Zufall ───────────────────────────────────────────────────
|
||||||
|
# openssl ist auf den meisten Systemen da, aber nicht auf allen. /dev/urandom
|
||||||
|
# gibt es ueberall, wo diese Shell laeuft – und base64 ebenso.
|
||||||
|
zufall() {
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -base64 32
|
||||||
|
else
|
||||||
|
head -c 32 /dev/urandom | base64
|
||||||
|
fi | tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$env_datei" ] && [ "$force" -eq 0 ]; then
|
||||||
|
echo "${gelb}·${aus} .env existiert bereits – sie wird nicht ueberschrieben."
|
||||||
|
echo " ${dim}Mit --force neu anlegen (die alte wird gesichert).${aus}"
|
||||||
|
else
|
||||||
|
if [ -f "$env_datei" ]; then
|
||||||
|
sicherung="$env_datei.$(date +%Y%m%d%H%M%S).bak"
|
||||||
|
cp "$env_datei" "$sicherung"
|
||||||
|
echo "${gelb}·${aus} Alte .env gesichert: $(basename "$sicherung")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
secret="$(zufall)"
|
||||||
|
# Das Geheimnis kann / und + enthalten; als sed-Ersetzung waeren das
|
||||||
|
# Sonderzeichen. Deshalb zeilenweise ersetzen statt mit einem Ausdruck.
|
||||||
|
: > "$env_datei"
|
||||||
|
while IFS= read -r zeile || [ -n "$zeile" ]; do
|
||||||
|
case "$zeile" in
|
||||||
|
APP_SECRET=*) printf 'APP_SECRET="%s"\n' "$secret" >> "$env_datei" ;;
|
||||||
|
*) printf '%s\n' "$zeile" >> "$env_datei" ;;
|
||||||
|
esac
|
||||||
|
done < "$vorlage"
|
||||||
|
|
||||||
|
chmod 600 "$env_datei"
|
||||||
|
echo "${gruen}✓${aus} .env angelegt, APP_SECRET zufaellig erzeugt, Rechte auf 600."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "${cyan}Noch anzupassen${aus}"
|
||||||
|
echo "${dim}─────────────────────────────────────────────${aus}"
|
||||||
|
echo " APP_URL die Adresse, unter der du Nebula aufrufst"
|
||||||
|
echo " ${dim}bestimmt die WebAuthn-rpID – passt sie nicht, scheitern Passkeys${aus}"
|
||||||
|
echo " POSTGRES_PASSWORD die Voreinstellung ist fuer Entwicklung gedacht"
|
||||||
|
echo " APP_PORT auf einem Server: 127.0.0.1:3000 hinter den Reverse Proxy"
|
||||||
|
echo
|
||||||
|
echo "${cyan}Danach${aus}"
|
||||||
|
echo "${dim}─────────────────────────────────────────────${aus}"
|
||||||
|
echo " docker compose --profile full up -d --build"
|
||||||
|
echo " ${dim}Der Container spielt die Migrationen beim Start selbst ein.${aus}"
|
||||||
|
echo
|
||||||
|
echo " Ausfuehrlich: DEPLOYMENT.md"
|
||||||
|
echo
|
||||||
Executable
+194
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Nebula auf einem Server aktualisieren.
|
||||||
|
#
|
||||||
|
# Holt den neuen Stand, sichert die Datenbank, baut das Abbild, spielt die
|
||||||
|
# Migrationen ein und startet neu. Bricht ab, sobald ein Schritt scheitert –
|
||||||
|
# und sagt dann, wie zurueckzukommen ist.
|
||||||
|
#
|
||||||
|
# Aufruf: ./scripts/update.sh
|
||||||
|
# ./scripts/update.sh --no-pull (nur neu bauen und starten)
|
||||||
|
# ./scripts/update.sh --ref v1.4.0 (auf einen Stand festlegen)
|
||||||
|
# ./scripts/update.sh --yes (ohne Rueckfrage)
|
||||||
|
#
|
||||||
|
# Rueckgabe 0 heisst: die neue Fassung laeuft und antwortet gesund.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
pull=1
|
||||||
|
ref=""
|
||||||
|
zusage=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--no-pull) pull=0 ;;
|
||||||
|
--ref) ref="${2:-}"; shift ;;
|
||||||
|
--yes|-y) zusage=1 ;;
|
||||||
|
-h|--help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Farben, aber nur wenn ein Terminal zusieht ───────────────
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
rot=$'\033[31m'; gruen=$'\033[32m'; gelb=$'\033[33m'; fett=$'\033[1m'; aus=$'\033[0m'
|
||||||
|
else
|
||||||
|
rot=""; gruen=""; gelb=""; fett=""; aus=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Die Version aus der package.json lesen – ohne Node. Auf einem reinen
|
||||||
|
# Docker-Host ist keins installiert, und dieses Skript soll dort laufen.
|
||||||
|
paket_version() {
|
||||||
|
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/package.json" \
|
||||||
|
| head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
|
||||||
|
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
|
||||||
|
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
|
||||||
|
abbruch() { printf '\n%s✗ %s%s\n' "$rot" "$1" "$aus" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Voraussetzungen ──────────────────────────────────────────
|
||||||
|
schritt "Voraussetzungen"
|
||||||
|
|
||||||
|
command -v docker >/dev/null || abbruch "docker nicht gefunden."
|
||||||
|
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
|
||||||
|
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst './scripts/setup.sh' ausfuehren."
|
||||||
|
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
|
||||||
|
ok "docker, compose und .env vorhanden"
|
||||||
|
|
||||||
|
# APP_SECRET muss gesetzt sein – sonst liegen die Git-Tokens faktisch offen.
|
||||||
|
if grep -qE '^\s*APP_SECRET\s*=\s*("?)(nebula-dev-secret[^"]*|)\1\s*$' "$root/.env"; then
|
||||||
|
abbruch "APP_SECRET ist leer oder noch der Entwicklungswert. Bitte in der .env setzen."
|
||||||
|
fi
|
||||||
|
ok "APP_SECRET gesetzt"
|
||||||
|
|
||||||
|
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
|
||||||
|
version_vorher="$(paket_version)"
|
||||||
|
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
|
||||||
|
|
||||||
|
# ── Neuen Stand holen ────────────────────────────────────────
|
||||||
|
if [ "$pull" -eq 1 ]; then
|
||||||
|
schritt "Neuen Stand holen"
|
||||||
|
|
||||||
|
schmutz="$(git status --porcelain)"
|
||||||
|
if [ -n "$schmutz" ]; then
|
||||||
|
# Nicht nur meckern, sondern zeigen, worum es geht – sonst muss man
|
||||||
|
# raten, was man da eigentlich verwerfen soll.
|
||||||
|
printf '\n%sGeaendert gegenueber dem Stand im Git:%s\n' "$fett" "$aus"
|
||||||
|
printf '%s\n' "$schmutz" | sed 's/^/ /'
|
||||||
|
printf '\n Verwerfen: git checkout -- .\n'
|
||||||
|
printf ' Behalten: git stash\n'
|
||||||
|
abbruch "Das Arbeitsverzeichnis ist nicht sauber."
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --all --tags --quiet
|
||||||
|
if [ -n "$ref" ]; then
|
||||||
|
git checkout --quiet "$ref"
|
||||||
|
ok "Auf $ref gewechselt"
|
||||||
|
else
|
||||||
|
zweig="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
git pull --ff-only --quiet origin "$zweig"
|
||||||
|
ok "$zweig aktualisiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nachher="$(git rev-parse --short HEAD)"
|
||||||
|
version_nachher="$(paket_version)"
|
||||||
|
|
||||||
|
if [ "$vorher" = "$nachher" ]; then
|
||||||
|
warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
|
||||||
|
else
|
||||||
|
printf ' Neu: v%s (%s)\n' "$version_nachher" "$nachher"
|
||||||
|
printf '\n Was dazukam:\n'
|
||||||
|
git --no-pager log --oneline --no-decorate "$vorher..$nachher" 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warnung "Ohne --pull: der vorhandene Stand wird neu gebaut."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Rueckfrage ───────────────────────────────────────────────
|
||||||
|
if [ "$zusage" -eq 0 ] && [ -t 0 ]; then
|
||||||
|
printf '\n%sWeiter? Die Datenbank wird vorher gesichert. [j/N] %s' "$fett" "$aus"
|
||||||
|
read -r antwort
|
||||||
|
case "$antwort" in [jJyY]*) ;; *) abbruch "Abgebrochen." ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Datenbank sichern ────────────────────────────────────────
|
||||||
|
schritt "Datenbank sichern"
|
||||||
|
|
||||||
|
# Die Datenbank muss laufen, sonst gibt es nichts zu sichern.
|
||||||
|
docker compose up -d db >/dev/null
|
||||||
|
sicherung=""
|
||||||
|
if [ -x "$root/scripts/backup.sh" ]; then
|
||||||
|
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
|
||||||
|
# backup.sh setzt den Pfad in Anfuehrungszeichen – die gehoeren nicht in
|
||||||
|
# den Namen, sonst laesst sich der Vorschlag am Ende nicht kopieren.
|
||||||
|
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ "]*backups/[^ "]*\.sql[^ "]*' | tail -n 1 || true)"
|
||||||
|
ok "Gesichert${sicherung:+: $sicherung}"
|
||||||
|
else
|
||||||
|
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bauen ────────────────────────────────────────────────────
|
||||||
|
schritt "Abbild bauen"
|
||||||
|
# Erst bauen, dann umschalten: ein Fehlschlag laesst die alte Fassung laufen.
|
||||||
|
docker compose --profile full build app || abbruch "Der Bau ist gescheitert. Die laufende Fassung ist unveraendert."
|
||||||
|
ok "Abbild gebaut"
|
||||||
|
|
||||||
|
# ── Starten ──────────────────────────────────────────────────
|
||||||
|
schritt "Starten"
|
||||||
|
# Die Migrationen laufen im entrypoint.sh, bevor der Server hochkommt.
|
||||||
|
docker compose --profile full up -d || abbruch "Der Start ist gescheitert."
|
||||||
|
ok "Container gestartet"
|
||||||
|
|
||||||
|
# ── Gesundheitspruefung ──────────────────────────────────────
|
||||||
|
schritt "Gesundheitspruefung"
|
||||||
|
|
||||||
|
# Gefragt wird der Container selbst, nicht der Host. Das Abbild bringt einen
|
||||||
|
# HEALTHCHECK mit, und so ist es einerlei, ob APP_PORT nur eine Nummer ist
|
||||||
|
# oder wie auf einem Server "127.0.0.1:3000" – und curl braucht es auch nicht.
|
||||||
|
gesund=0
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
zustand="$(docker inspect --format '{{.State.Health.Status}}' nebula-app 2>/dev/null || echo "weg")"
|
||||||
|
case "$zustand" in
|
||||||
|
healthy) gesund=1; break ;;
|
||||||
|
unhealthy) break ;;
|
||||||
|
esac
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$gesund" -ne 1 ]; then
|
||||||
|
printf '\n%sLetzte Zeilen aus dem Log:%s\n' "$fett" "$aus"
|
||||||
|
docker compose --profile full logs --tail 40 app || true
|
||||||
|
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
|
||||||
|
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
|
||||||
|
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
|
||||||
|
abbruch "Der Container meldet sich nach zwei Minuten nicht gesund (Zustand: ${zustand})."
|
||||||
|
fi
|
||||||
|
ok "Container meldet sich gesund"
|
||||||
|
|
||||||
|
# Die Anzeige der Adresse. Nicht die .env raten, sondern Docker fragen: mit
|
||||||
|
# docker-compose.server.yml veroeffentlicht der Container gar keinen Port und
|
||||||
|
# haengt nur im Netz des Reverse Proxy. Dann gibt "port" nichts zurueck.
|
||||||
|
adresse="$(docker compose --profile full port app 3000 2>/dev/null | tail -n 1 || true)"
|
||||||
|
|
||||||
|
if [ -n "$adresse" ]; then
|
||||||
|
port="${adresse##*:}"
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health" 2>/dev/null || true)"
|
||||||
|
[ -n "$antwort_json" ] && ok "Antwort: $antwort_json"
|
||||||
|
fi
|
||||||
|
wo="$adresse"
|
||||||
|
else
|
||||||
|
wo="im Docker-Netz (kein Host-Port veroeffentlicht)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Fertig ───────────────────────────────────────────────────
|
||||||
|
version_jetzt="$(paket_version)"
|
||||||
|
printf '\n%s%s✓ Nebula v%s laeuft auf %s%s\n' "$gruen" "$fett" "$version_jetzt" "$wo" "$aus"
|
||||||
|
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
|
||||||
|
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return { title: "Mein Konto" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccountPage() {
|
||||||
|
const user = await requireUserPage();
|
||||||
|
|
||||||
|
const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
|
||||||
|
prisma.credential.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
deviceType: true,
|
||||||
|
backedUp: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.session.findMany({
|
||||||
|
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userAgent: true,
|
||||||
|
ip: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastSeenAt: true,
|
||||||
|
},
|
||||||
|
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" },
|
||||||
|
}),
|
||||||
|
prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpEnabledAt: true },
|
||||||
|
}),
|
||||||
|
prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: AccountData = {
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
hasPassword: user.hasPassword,
|
||||||
|
passkeys,
|
||||||
|
sessions,
|
||||||
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
|
totp: {
|
||||||
|
enabled: Boolean(konto.totpEnabledAt),
|
||||||
|
enabledAt: konto.totpEnabledAt,
|
||||||
|
recoveryCodesLeft: offeneCodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 pt-4">
|
||||||
|
<header className="fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Profil, Anmeldemittel und aktive Sitzungen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AccountManager data={data} />
|
||||||
|
|
||||||
|
<NotificationSection
|
||||||
|
targets={targets as unknown as NotificationTargetEntry[]}
|
||||||
|
emailAvailable={emailAvailable()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import {
|
||||||
|
AdminManager,
|
||||||
|
type AdminData,
|
||||||
|
type AutoSyncReport,
|
||||||
|
} from "@/components/admin/AdminManager";
|
||||||
|
import { requireAdminPage } from "@/lib/auth/guard";
|
||||||
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
|
import { userSafeSelect } from "@/lib/auth/users";
|
||||||
|
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return { title: "Admin-Einstellungen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const admin = await requireAdminPage();
|
||||||
|
|
||||||
|
const [settings, users, eligible, staleListeners] = await Promise.all([
|
||||||
|
getAppSettings(),
|
||||||
|
prisma.user.findMany({
|
||||||
|
select: userSafeSelect,
|
||||||
|
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||||
|
}),
|
||||||
|
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
||||||
|
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
||||||
|
// Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
|
||||||
|
// der abgeglichen und begrenzt wird.
|
||||||
|
prisma.repository.count({
|
||||||
|
where: {
|
||||||
|
autoSync: true,
|
||||||
|
OR: [
|
||||||
|
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||||
|
{ repoUrl: { not: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.notificationTarget.count({
|
||||||
|
where: { isActive: true, events: { has: "PROJECT_STALE" } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: AdminData = {
|
||||||
|
mode: settings.mode,
|
||||||
|
allowRegistration: settings.allowRegistration,
|
||||||
|
users,
|
||||||
|
currentUserId: admin.id,
|
||||||
|
appUrl: APP_URL,
|
||||||
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
|
autoSync: {
|
||||||
|
enabled: settings.autoSyncEnabled,
|
||||||
|
intervalMinutes: settings.autoSyncIntervalMinutes,
|
||||||
|
activeOnly: settings.autoSyncActiveOnly,
|
||||||
|
batchSize: settings.autoSyncBatchSize,
|
||||||
|
lastRunAt: settings.lastAutoSyncAt,
|
||||||
|
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
|
||||||
|
eligible,
|
||||||
|
},
|
||||||
|
stale: {
|
||||||
|
afterDays: settings.staleAfterDays,
|
||||||
|
lastCheckAt: settings.lastStaleCheckAt,
|
||||||
|
listeners: staleListeners,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6 pt-4">
|
||||||
|
<header className="fade-up">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
|
||||||
|
Administration
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
|
||||||
|
Admin-Einstellungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<AdminManager data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Umbenennen – nur der eigene Passkey. */
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = passkeyNameSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
|
||||||
|
// gar nicht erst getroffen werden.
|
||||||
|
const result = await prisma.credential.updateMany({
|
||||||
|
where: { id, userId: auth.user.id },
|
||||||
|
data: { name: parsed.data.name ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { passwordHash: true, _count: { select: { credentials: true } } },
|
||||||
|
});
|
||||||
|
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
||||||
|
|
||||||
|
// Nicht das letzte Anmeldemittel entfernen.
|
||||||
|
if (!user.passwordHash && user._count.credentials <= 1) {
|
||||||
|
return jsonError(
|
||||||
|
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
|
||||||
|
"Bitte zuerst ein Passwort vergeben.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
|
||||||
|
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { changePasswordSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Passwort setzen oder aendern.
|
||||||
|
*
|
||||||
|
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
|
||||||
|
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
|
||||||
|
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
|
||||||
|
*/
|
||||||
|
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 = changePasswordSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
|
||||||
|
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
||||||
|
|
||||||
|
if (user.passwordHash) {
|
||||||
|
const current = parsed.data.currentPassword ?? "";
|
||||||
|
const valid = await verifyPassword(current, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Das aktuelle Passwort stimmt nicht.",
|
||||||
|
fields: { currentPassword: "Falsch" },
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(parsed.data.newPassword);
|
||||||
|
if (weak) {
|
||||||
|
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
|
||||||
|
// ausstellen, damit der Wechsel nicht abmeldet.
|
||||||
|
await destroyAllSessions(user.id);
|
||||||
|
const session = await createSession(user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip: clientIp(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function PATCH(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 = profileUpdateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { displayName, email } = parsed.data;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const taken = await prisma.user.findFirst({
|
||||||
|
where: { email, NOT: { id: auth.user.id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
data: {
|
||||||
|
...(displayName !== undefined ? { displayName } : {}),
|
||||||
|
...(email !== undefined ? { email } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, username: true, displayName: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientIp } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Uebersicht der eigenen aktiven Sitzungen. */
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const sessions = await prisma.session.findMany({
|
||||||
|
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userAgent: true,
|
||||||
|
ip: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastSeenAt: true,
|
||||||
|
expiresAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { lastSeenAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
await destroyAllSessions(auth.user.id);
|
||||||
|
const session = await createSession(auth.user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip: clientIp(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { issueRecoveryCodes } from "@/lib/auth/recovery";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const schema = z.object({ password: z.string().min(1).max(200) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren
|
||||||
|
* Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten.
|
||||||
|
*
|
||||||
|
* Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig
|
||||||
|
* zum zweiten Faktor.
|
||||||
|
*/
|
||||||
|
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 = schema.safeParse(body.data);
|
||||||
|
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { passwordHash: true, totpEnabledAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.totpEnabledAt) {
|
||||||
|
return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
|
||||||
|
}
|
||||||
|
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||||
|
return jsonError("Das Passwort stimmt nicht.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
|
||||||
|
import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
|
||||||
|
import { APP_NAME } from "@/lib/config";
|
||||||
|
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei-Faktor-Anmeldung per Einmalkennwort.
|
||||||
|
*
|
||||||
|
* Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und
|
||||||
|
* zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf.
|
||||||
|
* Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den
|
||||||
|
* QR-Code nie scannt, sperrt sich so nicht aus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zustand fuer die Anzeige. */
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { totpEnabledAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: Boolean(user.totpEnabledAt),
|
||||||
|
enabledAt: user.totpEnabledAt,
|
||||||
|
recoveryCodesLeft: user.totpEnabledAt
|
||||||
|
? await countUnusedRecoveryCodes(auth.user.id)
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */
|
||||||
|
export async function POST() {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { totpEnabledAt: true, username: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.totpEnabledAt) {
|
||||||
|
return jsonError(
|
||||||
|
"Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus
|
||||||
|
// einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne
|
||||||
|
// dass hier bekannt waere, in welcher.
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
data: { totpSecretCipher: encryptSecret(secret) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
// Das Bild als data:-URI, damit die Content-Security-Policy nichts
|
||||||
|
// nachladen muss. img-src erlaubt data: bereits.
|
||||||
|
qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }),
|
||||||
|
uri,
|
||||||
|
/** Zum Abtippen, falls die Kamera nicht mitspielt. */
|
||||||
|
secret: groupSecret(secret),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableSchema = z.object({ code: z.string().min(6).max(10) });
|
||||||
|
|
||||||
|
/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */
|
||||||
|
export async function PUT(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 = enableSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { totpSecretCipher: true, totpEnabledAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409);
|
||||||
|
|
||||||
|
const secret = tryDecryptSecret(user.totpSecretCipher);
|
||||||
|
if (!secret) {
|
||||||
|
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fenster = matchTotp(secret, parsed.data.code);
|
||||||
|
if (fenster === null) {
|
||||||
|
return jsonError(
|
||||||
|
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
// Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
|
||||||
|
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
|
||||||
|
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
// Einmalige Ausgabe: gespeichert wird nur ihr Hash.
|
||||||
|
recoveryCodes: await issueRecoveryCodes(auth.user.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableSchema = z.object({ password: z.string().min(1).max(200) });
|
||||||
|
|
||||||
|
/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */
|
||||||
|
export async function DELETE(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 = disableSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
select: { passwordHash: true, totpEnabledAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
|
||||||
|
|
||||||
|
// Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor
|
||||||
|
// nicht im Vorbeigehen entfernen koennen.
|
||||||
|
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||||
|
return jsonError("Das Passwort stimmt nicht.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: auth.user.id },
|
||||||
|
data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
|
||||||
|
}),
|
||||||
|
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
|
||||||
|
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { APP_NAME } from "@/lib/config";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { exportFileName } from "@/lib/export/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollstaendige Sicherung der Instanz als JSON.
|
||||||
|
*
|
||||||
|
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
|
||||||
|
* deshalb auch Konten und Verbindungen. Was drinsteht:
|
||||||
|
*
|
||||||
|
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
|
||||||
|
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
|
||||||
|
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
|
||||||
|
* Passkeys haengen ohnehin am Geraet und an der rpID
|
||||||
|
*
|
||||||
|
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
|
||||||
|
* der Datenbank siehe scripts/backup.* mit pg_dump.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const [settings, users, connections, projects] = await Promise.all([
|
||||||
|
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||||
|
prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
email: true,
|
||||||
|
passwordHash: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.gitConnection.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
name: true,
|
||||||
|
provider: true,
|
||||||
|
baseUrl: true,
|
||||||
|
username: true,
|
||||||
|
tokenCipher: true,
|
||||||
|
tokenHint: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.project.findMany({
|
||||||
|
include: {
|
||||||
|
notes: { orderBy: { createdAt: "asc" } },
|
||||||
|
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
|
||||||
|
// Snapshots bleiben draussen: reproduzierbar und gross.
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
kind: "nebula.backup",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
app: APP_NAME,
|
||||||
|
note:
|
||||||
|
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
|
||||||
|
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
|
||||||
|
settings,
|
||||||
|
users,
|
||||||
|
connections,
|
||||||
|
projects,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(payload, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { getAppSettings } from "@/lib/auth/settings";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
|
||||||
|
return NextResponse.json({ settings, userCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = adminSettingsSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
allowRegistration,
|
||||||
|
autoSyncEnabled,
|
||||||
|
autoSyncIntervalMinutes,
|
||||||
|
autoSyncActiveOnly,
|
||||||
|
autoSyncBatchSize,
|
||||||
|
staleAfterDays,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
|
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||||
|
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
||||||
|
if (mode === "SINGLE_USER") {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount > 1) {
|
||||||
|
return jsonError(
|
||||||
|
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
|
||||||
|
"Bitte zuerst die uebrigen Konten entfernen.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoSyncFields = {
|
||||||
|
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
||||||
|
...(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({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
create: {
|
||||||
|
id: "singleton",
|
||||||
|
...(mode ? { mode } : {}),
|
||||||
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
|
...autoSyncFields,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(mode ? { mode } : {}),
|
||||||
|
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||||
|
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||||
|
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||||
|
...autoSyncFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ settings });
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
|
||||||
|
*
|
||||||
|
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
|
||||||
|
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
|
||||||
|
* eingebauten Automatismus zu nutzen.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`sync-run:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
if (isAutoSyncRunning()) {
|
||||||
|
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await runAutoSyncPass("manuell");
|
||||||
|
if (!report) {
|
||||||
|
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ report });
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||||
|
import { destroyAllSessions } from "@/lib/auth/session";
|
||||||
|
import { userSafeSelect } from "@/lib/auth/users";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
|
||||||
|
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (target?.role !== "ADMIN") return false;
|
||||||
|
|
||||||
|
const activeAdmins = await prisma.user.count({
|
||||||
|
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
|
||||||
|
});
|
||||||
|
return activeAdmins === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = adminUserUpdateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) return jsonError("Konto nicht gefunden.", 404);
|
||||||
|
|
||||||
|
const { displayName, email, role, isActive, password } = parsed.data;
|
||||||
|
|
||||||
|
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
|
||||||
|
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
|
||||||
|
return jsonError(
|
||||||
|
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const taken = await prisma.user.findFirst({
|
||||||
|
where: { email, NOT: { id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const weak = validatePasswordStrength(password);
|
||||||
|
if (weak) {
|
||||||
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(displayName !== undefined ? { displayName } : {}),
|
||||||
|
...(email !== undefined ? { email } : {}),
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(isActive !== undefined ? { isActive } : {}),
|
||||||
|
...(password ? { passwordHash: await hashPassword(password) } : {}),
|
||||||
|
// Ein Eingriff des Administrators hebt eine Sperre auf.
|
||||||
|
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
|
||||||
|
},
|
||||||
|
select: userSafeSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
|
||||||
|
if (isActive === false || password) {
|
||||||
|
await destroyAllSessions(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (id === auth.user.id) {
|
||||||
|
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
|
||||||
|
}
|
||||||
|
if (await wouldRemoveLastAdmin(id)) {
|
||||||
|
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
|
||||||
|
// onDelete: Cascade daran und verschwinden mit.
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return jsonError("Konto nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminApi } from "@/lib/auth/guard";
|
||||||
|
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||||
|
import { getInstanceState } from "@/lib/auth/settings";
|
||||||
|
import { userSafeSelect } from "@/lib/auth/users";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: userSafeSelect,
|
||||||
|
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const state = await getInstanceState();
|
||||||
|
if (!state.multiUser) {
|
||||||
|
return jsonError(
|
||||||
|
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
|
||||||
|
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = adminUserCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, displayName, email, password, role } = parsed.data;
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(password);
|
||||||
|
if (weak) {
|
||||||
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
||||||
|
if (taken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||||
|
if (emailTaken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
email: email ?? null,
|
||||||
|
passwordHash: await hashPassword(password),
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
select: userSafeSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
|
||||||
|
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { loginSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
|
||||||
|
const GENERIC = "Benutzername oder Passwort ist falsch.";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
|
||||||
|
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||||
|
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = loginSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) return jsonError(GENERIC, 401);
|
||||||
|
|
||||||
|
const { username, password } = parsed.data;
|
||||||
|
|
||||||
|
const byUser = rateLimit(
|
||||||
|
`login:user:${username}`,
|
||||||
|
LIMITS.loginUser.limit,
|
||||||
|
LIMITS.loginUser.windowMs,
|
||||||
|
);
|
||||||
|
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
|
||||||
|
await fakeVerifyDelay();
|
||||||
|
return jsonError(GENERIC, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locked = lockRemainingSeconds(user.lockedUntil);
|
||||||
|
if (locked > 0) {
|
||||||
|
return jsonError(
|
||||||
|
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
|
||||||
|
423,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
await fakeVerifyDelay();
|
||||||
|
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
await fakeVerifyDelay();
|
||||||
|
return jsonError(
|
||||||
|
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
await noteFailedLogin(user.id);
|
||||||
|
return jsonError(GENERIC, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
|
||||||
|
// noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist.
|
||||||
|
if (user.totpEnabledAt) {
|
||||||
|
// Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist:
|
||||||
|
// sonst liesse sich ueber wiederholte Passworteingaben die Sperre
|
||||||
|
// umgehen, die falsche Passwoerter aufbaut.
|
||||||
|
const token = await createPendingLogin(user.id);
|
||||||
|
const response = NextResponse.json({ ok: false, mfaRequired: true });
|
||||||
|
setMfaCookie(response, token);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
await noteSuccessfulLogin(user.id);
|
||||||
|
resetRateLimit(`login:user:${username}`);
|
||||||
|
resetRateLimit(`login:ip:${ip}`);
|
||||||
|
|
||||||
|
const session = await createSession(user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
|
});
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await destroyCurrentSession();
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
clearSessionCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
clearMfaCookie,
|
||||||
|
discardPendingLogin,
|
||||||
|
noteMfaFailure,
|
||||||
|
readPendingLogin,
|
||||||
|
} from "@/lib/auth/mfa";
|
||||||
|
import { redeemRecoveryCode } from "@/lib/auth/recovery";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { matchTotp } from "@/lib/auth/totp";
|
||||||
|
import { noteSuccessfulLogin } from "@/lib/auth/users";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der zweite Schritt der Anmeldung: Einmalkennwort oder
|
||||||
|
* Wiederherstellungscode.
|
||||||
|
*
|
||||||
|
* Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne
|
||||||
|
* diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
code: z.string().min(1).max(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
const GENERIC = "Der Code stimmt nicht.";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(body.data);
|
||||||
|
if (!parsed.success) return jsonError(GENERIC, 401);
|
||||||
|
|
||||||
|
const pending = await readPendingLogin();
|
||||||
|
if (!pending) {
|
||||||
|
const response = jsonError(
|
||||||
|
"Die Anmeldung ist abgelaufen. Bitte melde dich neu an.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
clearMfaCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending.user.isActive) {
|
||||||
|
await discardPendingLogin(pending.id);
|
||||||
|
const response = jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||||
|
clearMfaCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = parsed.data.code.trim();
|
||||||
|
|
||||||
|
// Sechs Ziffern sind ein Einmalkennwort, alles andere ein
|
||||||
|
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
|
||||||
|
// Formate ueberschneiden sich nicht.
|
||||||
|
const istZiffern = /^[\d\s]+$/.test(eingabe);
|
||||||
|
|
||||||
|
// Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
|
||||||
|
// das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
|
||||||
|
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
|
||||||
|
const fenster =
|
||||||
|
istZiffern && pending.user.totpSecret
|
||||||
|
? matchTotp(pending.user.totpSecret, eingabe)
|
||||||
|
: null;
|
||||||
|
const frisch =
|
||||||
|
fenster !== null &&
|
||||||
|
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
|
||||||
|
|
||||||
|
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
|
||||||
|
|
||||||
|
if (!erfolg) {
|
||||||
|
const weiterMoeglich = await noteMfaFailure(pending.id);
|
||||||
|
if (!weiterMoeglich) {
|
||||||
|
const response = jsonError(
|
||||||
|
"Zu viele Fehlversuche. Bitte melde dich neu an.",
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
clearMfaCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return jsonError(GENERIC, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
await discardPendingLogin(pending.id);
|
||||||
|
await noteSuccessfulLogin(pending.user.id);
|
||||||
|
|
||||||
|
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
|
||||||
|
if (fenster !== null) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: pending.user.id },
|
||||||
|
data: { totpLastCounter: fenster },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createSession(pending.user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: pending.user.id, username: pending.user.username, role: pending.user.role },
|
||||||
|
});
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
clearMfaCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der
|
||||||
|
* Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht
|
||||||
|
* noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const limited = rateLimit(
|
||||||
|
`webauthn:auth:${clientIp(request)}`,
|
||||||
|
LIMITS.webauthn.limit,
|
||||||
|
LIMITS.webauthn.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await buildAuthenticationOptions();
|
||||||
|
return NextResponse.json({ options });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||||
|
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { noteSuccessfulLogin } from "@/lib/auth/users";
|
||||||
|
import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface Payload {
|
||||||
|
response?: AuthenticationResponseJSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody<Payload>(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const assertion = body.data?.response;
|
||||||
|
if (!assertion || typeof assertion !== "object") {
|
||||||
|
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await confirmAuthentication(assertion);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: result.userId } });
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await noteSuccessfulLogin(user.id);
|
||||||
|
|
||||||
|
const session = await createSession(user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
|
});
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||||
|
return jsonError(error instanceof Error ? error.message : String(error), 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Liefert die Optionen fuer navigator.credentials.create(). */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`webauthn:reg:${clientIp(request)}`,
|
||||||
|
LIMITS.webauthn.limit,
|
||||||
|
LIMITS.webauthn.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await buildRegistrationOptions({
|
||||||
|
id: auth.user.id,
|
||||||
|
username: auth.user.username,
|
||||||
|
displayName: auth.user.displayName,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ options });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||||
|
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface Payload {
|
||||||
|
response?: RegistrationResponseJSON;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const body = await readJsonBody<Payload>(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const attestation = body.data?.response;
|
||||||
|
if (!attestation || typeof attestation !== "object") {
|
||||||
|
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await confirmRegistration(
|
||||||
|
auth.user.id,
|
||||||
|
attestation,
|
||||||
|
typeof body.data?.name === "string" ? body.data.name : null,
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
name: credential.name,
|
||||||
|
deviceType: credential.deviceType,
|
||||||
|
backedUp: credential.backedUp,
|
||||||
|
createdAt: credential.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||||
|
return jsonError(error instanceof Error ? error.message : String(error), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { getInstanceState } from "@/lib/auth/settings";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { formatZodError, registerSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb
|
||||||
|
* laeuft und ein Administrator die Registrierung freigeschaltet hat.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const state = await getInstanceState();
|
||||||
|
if (state.needsSetup) {
|
||||||
|
return jsonError("Die Instanz ist noch nicht eingerichtet.", 409);
|
||||||
|
}
|
||||||
|
if (!state.registrationOpen) {
|
||||||
|
return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = registerSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, displayName, email, password } = parsed.data;
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(password);
|
||||||
|
if (weak) {
|
||||||
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
||||||
|
if (taken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||||
|
if (emailTaken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
email: email ?? null,
|
||||||
|
passwordHash: await hashPassword(password),
|
||||||
|
role: "USER",
|
||||||
|
// Die Registrierung meldet direkt an.
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await createSession(user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
|
});
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/auth/session";
|
||||||
|
import { getInstanceState } from "@/lib/auth/settings";
|
||||||
|
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */
|
||||||
|
export async function GET() {
|
||||||
|
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user,
|
||||||
|
needsSetup: state.needsSetup,
|
||||||
|
registrationOpen: state.registrationOpen,
|
||||||
|
multiUser: state.multiUser,
|
||||||
|
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { claimOrphanedData } from "@/lib/auth/users";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, setupSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstmalige Einrichtung: legt das Administratorkonto an.
|
||||||
|
*
|
||||||
|
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
|
||||||
|
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
|
||||||
|
* erschleichen.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const existing = await prisma.user.count();
|
||||||
|
if (existing > 0) {
|
||||||
|
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = setupSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, displayName, email, password, mode } = parsed.data;
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(password);
|
||||||
|
if (weak) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: weak, fields: { password: weak } },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
email: email ?? null,
|
||||||
|
passwordHash: await hashPassword(password),
|
||||||
|
role: "ADMIN",
|
||||||
|
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
||||||
|
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.appSettings.upsert({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
create: { id: "singleton", mode },
|
||||||
|
update: { mode },
|
||||||
|
});
|
||||||
|
|
||||||
|
const claimed = await claimOrphanedData(user.id);
|
||||||
|
|
||||||
|
const session = await createSession(user.id, {
|
||||||
|
userAgent: request.headers.get("user-agent"),
|
||||||
|
ip: clientIp(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
|
claimed,
|
||||||
|
});
|
||||||
|
setSessionCookie(response, session.token, session.expiresAt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientForConnection } from "@/lib/git";
|
||||||
|
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 }> };
|
||||||
|
|
||||||
|
/** Testet Erreichbarkeit und Token einer Verbindung und merkt sich das Ergebnis. */
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
|
||||||
|
|
||||||
|
const result = await clientForConnection(connection).check();
|
||||||
|
|
||||||
|
await prisma.gitConnection.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
lastCheckedAt: new Date(),
|
||||||
|
lastCheckOk: result.ok,
|
||||||
|
lastCheckMessage: result.message,
|
||||||
|
username: result.user ?? connection.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientForConnection } from "@/lib/git";
|
||||||
|
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 }> };
|
||||||
|
|
||||||
|
/** Listet Repositories, auf die der hinterlegte Token Zugriff hat. */
|
||||||
|
export async function GET(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const query = new URL(request.url).searchParams.get("q") ?? undefined;
|
||||||
|
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repos = await clientForConnection(connection).listRepos(query);
|
||||||
|
return NextResponse.json({ repos });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { normalizeBaseUrl } from "@/lib/git/http";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||||
|
import { connectionUpdateSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const safeSelect = {
|
||||||
|
...connectionSafeSelect,
|
||||||
|
_count: { select: { repositories: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
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.gitConnection.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return jsonError("Verbindung nicht gefunden", 404);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = connectionUpdateSchema.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.provider !== undefined) update.provider = data.provider;
|
||||||
|
if (data.username !== undefined) update.username = data.username;
|
||||||
|
|
||||||
|
if (data.baseUrl !== undefined) {
|
||||||
|
const normalized = normalizeBaseUrl(data.baseUrl);
|
||||||
|
try {
|
||||||
|
await assertAllowedUrl(normalized);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BlockedHostError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, fields: { baseUrl: error.message } },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
update.baseUrl = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// token === null -> Token entfernen
|
||||||
|
// token === "" -> vom Schema zu null normalisiert
|
||||||
|
// token undefined -> unveraendert lassen
|
||||||
|
if (data.token !== undefined) {
|
||||||
|
if (data.token) {
|
||||||
|
update.tokenCipher = encryptSecret(data.token);
|
||||||
|
update.tokenHint = tokenHint(data.token);
|
||||||
|
} else {
|
||||||
|
update.tokenCipher = null;
|
||||||
|
update.tokenHint = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
const duplicate = await prisma.gitConnection.findFirst({
|
||||||
|
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.gitConnection.update({
|
||||||
|
where: { id },
|
||||||
|
data: update,
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ connection });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
|
||||||
|
const result = await prisma.gitConnection.deleteMany({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return jsonError("Verbindung nicht gefunden", 404);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { normalizeBaseUrl } from "@/lib/git/http";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||||
|
import { connectionCreateSchema, formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
|
||||||
|
const safeSelect = {
|
||||||
|
...connectionSafeSelect,
|
||||||
|
_count: { select: { repositories: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const connections = await prisma.gitConnection.findMany({
|
||||||
|
where: { ownerId: auth.user.id },
|
||||||
|
select: safeSelect,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ connections });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`mutate:${clientIp(request)}`,
|
||||||
|
LIMITS.mutation.limit,
|
||||||
|
LIMITS.mutation.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = connectionCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, provider, baseUrl, username, token } = parsed.data;
|
||||||
|
|
||||||
|
// Die Basis-URL wird spaeter serverseitig abgerufen – hier pruefen, damit
|
||||||
|
// sie nicht auf interne Dienste oder Metadaten-Endpunkte zeigt.
|
||||||
|
try {
|
||||||
|
await assertAllowedUrl(normalizeBaseUrl(baseUrl));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BlockedHostError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, fields: { baseUrl: error.message } },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicate = await prisma.gitConnection.findFirst({
|
||||||
|
where: { name, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Es gibt bereits eine Verbindung mit diesem Namen.",
|
||||||
|
fields: { name: "Name bereits vergeben" },
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.gitConnection.create({
|
||||||
|
data: {
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
baseUrl: normalizeBaseUrl(baseUrl),
|
||||||
|
username: username ?? null,
|
||||||
|
tokenCipher: token ? encryptSecret(token) : null,
|
||||||
|
tokenHint: token ? tokenHint(token) : null,
|
||||||
|
},
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ connection }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Liveness/Readiness-Endpoint – wird auch vom Docker-Healthcheck benutzt. */
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
database: "up",
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "degraded",
|
||||||
|
database: "down",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, noteUpdateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Eine Notiz gehoert dem, dem das zugehoerige Projekt gehoert. */
|
||||||
|
async function ownedNote(noteId: string, ownerId: string) {
|
||||||
|
return prisma.note.findFirst({
|
||||||
|
where: { id: noteId, project: { ownerId } },
|
||||||
|
select: { id: true, projectId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const note = await ownedNote(id, auth.user.id);
|
||||||
|
if (!note) return jsonError("Notiz nicht gefunden", 404);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = noteUpdateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.note.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||||
|
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
|
||||||
|
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
|
||||||
|
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
|
||||||
|
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
|
||||||
|
await recordActivity({
|
||||||
|
projectId: note.projectId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "NOTE_UPDATED",
|
||||||
|
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
|
||||||
|
meta: { noteId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ note: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
|
||||||
|
const vorher = await prisma.note.findFirst({
|
||||||
|
where: { id, project: { ownerId: auth.user.id } },
|
||||||
|
select: { projectId: true, title: true, content: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prisma.note.deleteMany({
|
||||||
|
where: { id, project: { ownerId: auth.user.id } },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
|
||||||
|
|
||||||
|
if (vorher) {
|
||||||
|
await recordActivity({
|
||||||
|
projectId: vorher.projectId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "NOTE_DELETED",
|
||||||
|
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { ACTIVITY_LIMIT } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError } from "@/lib/security/request";
|
||||||
|
import { ownedProject } from "@/lib/tasks";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
|
||||||
|
export async function GET(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
|
||||||
|
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
|
||||||
|
const roh = new URL(request.url).searchParams.get("limit");
|
||||||
|
const angefragt = roh === null ? Number.NaN : Number(roh);
|
||||||
|
const take = Number.isFinite(angefragt)
|
||||||
|
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
|
||||||
|
: 30;
|
||||||
|
|
||||||
|
const activities = await prisma.activity.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
summary: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { username: true, displayName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ activities });
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { exportFileName } from "@/lib/export/format";
|
||||||
|
import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
|
||||||
|
import { jsonError } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelnes Projekt exportieren.
|
||||||
|
*
|
||||||
|
* ?format=json (Vorgabe) – zum Wiedereinlesen
|
||||||
|
* ?format=md – lesbar, zum Archivieren
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const format = new URL(request.url).searchParams.get("format") ?? "json";
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
include: {
|
||||||
|
...exportInclude,
|
||||||
|
// Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
|
||||||
|
// die Datei auch ohne Netz vollstaendig ist.
|
||||||
|
repositories: {
|
||||||
|
include: {
|
||||||
|
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||||
|
snapshot: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|
||||||
|
if (format === "md" || format === "markdown") {
|
||||||
|
const filename = exportFileName(project.slug, new Date(), "md");
|
||||||
|
return new Response(toMarkdown(project), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/markdown; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildExportFile([project]);
|
||||||
|
const filename = exportFileName(project.slug, new Date(), "json");
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(payload, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, noteCreateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Prueft in einem Zug Existenz und Besitz des Projekts. */
|
||||||
|
async function ownedProject(projectId: string, ownerId: string) {
|
||||||
|
return prisma.project.findFirst({
|
||||||
|
where: { id: projectId, ownerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json({ notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = noteCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
title: parsed.data.title ?? null,
|
||||||
|
content: parsed.data.content,
|
||||||
|
pinned: parsed.data.pinned,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
|
||||||
|
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "NOTE_ADDED",
|
||||||
|
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
|
||||||
|
meta: { noteId: note.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ note }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import {
|
||||||
|
completeRepoInput,
|
||||||
|
namesRepository,
|
||||||
|
nextRepoPosition,
|
||||||
|
repoTitle,
|
||||||
|
} from "@/lib/repositories";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { ownedProject } from "@/lib/tasks";
|
||||||
|
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
|
||||||
|
const MAX_REPOS = 20;
|
||||||
|
|
||||||
|
const include = {
|
||||||
|
connection: { select: connectionSafeSelect },
|
||||||
|
snapshot: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositories = await prisma.repository.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include,
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ repositories });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = repositoryCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = completeRepoInput(parsed.data);
|
||||||
|
if (!namesRepository(eingabe)) {
|
||||||
|
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine fremde Verbindung darf nicht untergeschoben werden.
|
||||||
|
if (eingabe.connectionId) {
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id: eingabe.connectionId, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
|
||||||
|
if (vorhanden >= MAX_REPOS) {
|
||||||
|
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
|
||||||
|
if (eingabe.repoOwner && eingabe.repoName) {
|
||||||
|
const doppelt = await prisma.repository.findFirst({
|
||||||
|
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (doppelt) {
|
||||||
|
return jsonError(
|
||||||
|
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = await prisma.repository.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
label: eingabe.label ?? null,
|
||||||
|
repoUrl: eingabe.repoUrl ?? null,
|
||||||
|
repoOwner: eingabe.repoOwner ?? null,
|
||||||
|
repoName: eingabe.repoName ?? null,
|
||||||
|
connectionId: eingabe.connectionId ?? null,
|
||||||
|
autoSync: eingabe.autoSync ?? true,
|
||||||
|
position: await nextRepoPosition(id),
|
||||||
|
},
|
||||||
|
include,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "REPO_LINKED",
|
||||||
|
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
|
||||||
|
meta: { repositoryId: repository.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ repository }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
nextProjectPosition,
|
||||||
|
projectDetailInclude,
|
||||||
|
projectListInclude,
|
||||||
|
uniqueSlug,
|
||||||
|
} from "@/lib/projects";
|
||||||
|
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { refreshDerivedProgress } from "@/lib/tasks";
|
||||||
|
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
include: projectDetailInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fremde Projekte werden wie nicht vorhandene behandelt – die Antwort
|
||||||
|
// verraet also nicht, dass es die ID gibt.
|
||||||
|
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = projectUpdateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.project.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
});
|
||||||
|
if (!existing) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
if (data.connectionId) {
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id: data.connectionId, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (data.name !== undefined && data.name !== existing.name) {
|
||||||
|
update.name = data.name;
|
||||||
|
update.slug = await uniqueSlug(data.name, auth.user.id, id);
|
||||||
|
}
|
||||||
|
for (const key of [
|
||||||
|
"summary",
|
||||||
|
"description",
|
||||||
|
"status",
|
||||||
|
"priority",
|
||||||
|
"progress",
|
||||||
|
"accent",
|
||||||
|
"tags",
|
||||||
|
"favorite",
|
||||||
|
"progressFromTasks",
|
||||||
|
] as const) {
|
||||||
|
if (data[key] !== undefined) update[key] = data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
|
||||||
|
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
|
||||||
|
// angekommen ist.
|
||||||
|
if (data.status !== undefined && data.status !== existing.status) {
|
||||||
|
update.position = await nextProjectPosition(auth.user.id, data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.update({ where: { id }, data: update });
|
||||||
|
|
||||||
|
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
||||||
|
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
|
||||||
|
const repoFelder = {
|
||||||
|
repoUrl: data.repoUrl,
|
||||||
|
repoOwner: data.repoOwner,
|
||||||
|
repoName: data.repoName,
|
||||||
|
connectionId: data.connectionId,
|
||||||
|
autoSync: data.autoSync,
|
||||||
|
};
|
||||||
|
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
|
||||||
|
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
|
||||||
|
|
||||||
|
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
|
||||||
|
|
||||||
|
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
|
||||||
|
// erst beim naechsten Aufgabenwechsel.
|
||||||
|
if (data.progressFromTasks === true) {
|
||||||
|
await refreshDerivedProgress(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: projectListInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
|
||||||
|
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
|
||||||
|
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
|
||||||
|
* Farbe den Verlauf.
|
||||||
|
*/
|
||||||
|
async function noteChanges(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
before: { status: ProjectStatus },
|
||||||
|
update: Record<string, unknown>,
|
||||||
|
repo: RepoInput | null,
|
||||||
|
) {
|
||||||
|
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
|
||||||
|
const entries: Parameters<typeof recordActivities>[0] = [];
|
||||||
|
|
||||||
|
if (update.status !== undefined && update.status !== before.status) {
|
||||||
|
const to = update.status as ProjectStatus;
|
||||||
|
entries.push({
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
kind: "STATUS_CHANGED",
|
||||||
|
summary: describeStatus(before.status, to),
|
||||||
|
meta: { from: before.status, to },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
|
||||||
|
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
|
||||||
|
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
|
||||||
|
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
|
||||||
|
entries.push({
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
kind: "REPO_LINKED",
|
||||||
|
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
|
||||||
|
meta: { repoUrl: repo.repoUrl ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (repo?.autoSync !== undefined) rest.push("autoSync");
|
||||||
|
if (repo?.connectionId !== undefined) rest.push("connectionId");
|
||||||
|
|
||||||
|
const felder = describeFields(rest);
|
||||||
|
if (felder) {
|
||||||
|
entries.push({
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
kind: "PROJECT_UPDATED",
|
||||||
|
summary: felder,
|
||||||
|
meta: { fields: rest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordActivities(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// deleteMany mit Besitzerbedingung: trifft fremde Projekte gar nicht erst.
|
||||||
|
const result = await prisma.project.deleteMany({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { projectDetailInclude } from "@/lib/projects";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { syncProject } from "@/lib/sync";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Holt Branches, Tags, Commits und Releases beim Git-System ab. */
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
|
||||||
|
// begrenzt als bei reinen Datenbankzugriffen.
|
||||||
|
const limited = rateLimit(
|
||||||
|
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const owned = await prisma.project.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!owned) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|
||||||
|
const result = await syncProject(id);
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id, ownerId: auth.user.id },
|
||||||
|
include: projectDetailInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ...result, project }, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { recordActivity } from "@/lib/activity";
|
||||||
|
import { repoTitle } from "@/lib/repositories";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
|
||||||
|
import { asArray, type TicketInfo } from "@/types";
|
||||||
|
import { formatZodError } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
|
||||||
|
numbers: z.array(z.number().int()).max(200).optional(),
|
||||||
|
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
|
||||||
|
repositoryId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt offene Issues des verknuepften Repositories als Aufgaben.
|
||||||
|
*
|
||||||
|
* Grundlage ist der zuletzt geholte Snapshot – hier wird nichts nachgeladen,
|
||||||
|
* damit ein Klick nicht ungefragt fremde APIs anspricht. Wer den neuesten
|
||||||
|
* Stand will, gleicht vorher ab.
|
||||||
|
*
|
||||||
|
* Bereits uebernommene Issues werden uebersprungen: die Nummer steht an der
|
||||||
|
* Aufgabe und ist je Projekt eindeutig. Ein zweiter Aufruf legt also keine
|
||||||
|
* Dubletten an, auch wenn die Aufgabe inzwischen verschoben oder umbenannt
|
||||||
|
* wurde.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(body.data ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
|
||||||
|
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
|
||||||
|
const repository = await prisma.repository.findFirst({
|
||||||
|
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { snapshot: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
|
||||||
|
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
|
||||||
|
return parsed.data.repositoryId
|
||||||
|
? jsonError("Repository nicht gefunden", 404)
|
||||||
|
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = repository.snapshot;
|
||||||
|
if (!snapshot) {
|
||||||
|
return jsonError(
|
||||||
|
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alle = asArray<TicketInfo>(snapshot.issues);
|
||||||
|
const gewuenscht = parsed.data.numbers;
|
||||||
|
const auswahl = gewuenscht?.length
|
||||||
|
? alle.filter((issue) => gewuenscht.includes(issue.number))
|
||||||
|
: alle;
|
||||||
|
|
||||||
|
if (auswahl.length === 0) {
|
||||||
|
return NextResponse.json({ created: 0, skipped: 0, tasks: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
sourceRepositoryId: repository.id,
|
||||||
|
sourceIssue: { in: auswahl.map((issue) => issue.number) },
|
||||||
|
},
|
||||||
|
select: { sourceIssue: true },
|
||||||
|
});
|
||||||
|
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
|
||||||
|
|
||||||
|
const neu = auswahl.filter((issue) => !bekannt.has(issue.number));
|
||||||
|
let position = await nextPosition(id, "TODO");
|
||||||
|
|
||||||
|
const angelegt = [];
|
||||||
|
for (const issue of neu) {
|
||||||
|
angelegt.push(
|
||||||
|
await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
title: issue.title.slice(0, 200),
|
||||||
|
// Der Link ist das Wichtigste – der Text steht im Git-System.
|
||||||
|
description: issue.url ? `[Issue #${issue.number}](${issue.url})` : null,
|
||||||
|
status: "TODO",
|
||||||
|
position: position++,
|
||||||
|
labels: issue.labels.slice(0, 8),
|
||||||
|
sourceIssue: issue.number,
|
||||||
|
sourceUrl: issue.url,
|
||||||
|
sourceRepositoryId: repository.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (angelegt.length > 0) {
|
||||||
|
await refreshDerivedProgress(id);
|
||||||
|
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
|
||||||
|
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
|
||||||
|
await recordActivity({
|
||||||
|
projectId: id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "TASKS_IMPORTED",
|
||||||
|
summary:
|
||||||
|
angelegt.length === 1
|
||||||
|
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
|
||||||
|
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
|
||||||
|
meta: {
|
||||||
|
repositoryId: repository.id,
|
||||||
|
numbers: angelegt.map((task) => task.sourceIssue),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
created: angelegt.length,
|
||||||
|
skipped: auswahl.length - angelegt.length,
|
||||||
|
tasks: angelegt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Prisma, TaskStatus } from "@prisma/client";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
|
||||||
|
import { formatZodError, taskReorderSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Reihenfolge nach dem Ziehen.
|
||||||
|
*
|
||||||
|
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
|
||||||
|
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
|
||||||
|
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
|
||||||
|
* und ein verlorener Aufruf laesst sich einfach erneut senden.
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = taskReorderSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
|
||||||
|
const requestedIds = Object.values(columns).flat();
|
||||||
|
|
||||||
|
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
|
||||||
|
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
|
||||||
|
const owned = await prisma.task.findMany({
|
||||||
|
where: { projectId: id, id: { in: requestedIds } },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((task) => task.id));
|
||||||
|
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
|
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
|
||||||
|
const frischErledigt: string[] = [];
|
||||||
|
|
||||||
|
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
|
||||||
|
ids.forEach((taskId, index) => {
|
||||||
|
if (!ownedIds.has(taskId)) return;
|
||||||
|
const before = previousStatus.get(taskId);
|
||||||
|
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
|
||||||
|
updates.push(
|
||||||
|
prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
position: index,
|
||||||
|
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
|
||||||
|
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
|
||||||
|
// immer wieder erneuert.
|
||||||
|
...(before !== status
|
||||||
|
? { doneAt: status === "DONE" ? now : null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await prisma.$transaction(updates);
|
||||||
|
|
||||||
|
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
|
||||||
|
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
|
||||||
|
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
|
||||||
|
for (const taskId of frischErledigt) {
|
||||||
|
await spawnRecurrence(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshDerivedProgress(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: [{ status: "asc" }, { position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tasks,
|
||||||
|
updated: updates.length,
|
||||||
|
ignored: requestedIds.length - updates.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
|
||||||
|
import { formatZodError, taskCreateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: [{ status: "asc" }, { position: "asc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json({ tasks });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = taskCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, status, dueDate, labels, recurrence } = parsed.data;
|
||||||
|
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
status,
|
||||||
|
// Neue Aufgaben landen unten in ihrer Spalte.
|
||||||
|
position: await nextPosition(id, status),
|
||||||
|
dueDate: dueDate ? new Date(dueDate) : null,
|
||||||
|
labels,
|
||||||
|
recurrence: recurrence ?? null,
|
||||||
|
doneAt: status === "DONE" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshDerivedProgress(id);
|
||||||
|
// Damit die Sortierung nach Aktivitaet stimmt.
|
||||||
|
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "TASK_ADDED",
|
||||||
|
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
|
||||||
|
meta: { taskId: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ task }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Prisma, ProjectStatus } from "@prisma/client";
|
||||||
|
import { describeStatus, recordActivities } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { formatZodError, projectBulkSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
|
||||||
|
*
|
||||||
|
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
|
||||||
|
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
|
||||||
|
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`mutate:${clientIp(request)}`,
|
||||||
|
LIMITS.mutation.limit,
|
||||||
|
LIMITS.mutation.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = projectBulkSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ids, action } = parsed.data;
|
||||||
|
|
||||||
|
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
|
||||||
|
// erst eingesammelt und fallen damit still unter den Tisch.
|
||||||
|
const owned = await prisma.project.findMany({
|
||||||
|
where: { ownerId: auth.user.id, id: { in: ids } },
|
||||||
|
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (owned.length === 0) {
|
||||||
|
return NextResponse.json({ changed: 0, ignored: ids.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eigene = owned.map((project) => project.id);
|
||||||
|
const entries: Parameters<typeof recordActivities>[0] = [];
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
const result = await prisma.project.deleteMany({
|
||||||
|
where: { ownerId: auth.user.id, id: { in: eigene } },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
|
|
||||||
|
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
|
||||||
|
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
|
||||||
|
// alle Verschobenen landeten uebereinander auf derselben Position.
|
||||||
|
let position =
|
||||||
|
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
|
||||||
|
|
||||||
|
for (const project of owned) {
|
||||||
|
if (action === "status") {
|
||||||
|
const ziel = parsed.data.status!;
|
||||||
|
if (project.status === ziel) continue;
|
||||||
|
updates.push(
|
||||||
|
prisma.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
|
||||||
|
data: { status: ziel, position: position++ },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
entries.push({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "STATUS_CHANGED",
|
||||||
|
summary: describeStatus(project.status, ziel),
|
||||||
|
meta: { from: project.status, to: ziel, bulk: true },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "favorite") {
|
||||||
|
const ziel = parsed.data.favorite!;
|
||||||
|
if (project.favorite === ziel) continue;
|
||||||
|
updates.push(
|
||||||
|
prisma.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
// Ein Stern ist keine inhaltliche Aenderung.
|
||||||
|
data: { favorite: ziel, updatedAt: project.updatedAt },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === "tags"
|
||||||
|
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
|
||||||
|
const naechste =
|
||||||
|
parsed.data.tagMode === "add"
|
||||||
|
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
|
||||||
|
: project.tags.filter((tag) => !gewuenscht.includes(tag));
|
||||||
|
|
||||||
|
if (naechste.length === project.tags.length &&
|
||||||
|
naechste.every((tag, index) => tag === project.tags[index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(
|
||||||
|
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
|
||||||
|
);
|
||||||
|
entries.push({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "PROJECT_UPDATED",
|
||||||
|
summary: "Tags geändert",
|
||||||
|
meta: { tags: naechste, bulk: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await prisma.$transaction(updates);
|
||||||
|
await recordActivities(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
changed: updates.length,
|
||||||
|
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
|
||||||
|
ignored: ids.length - updates.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naechste freie Position am Ende einer Statusspalte. */
|
||||||
|
async function nextPosition(ownerId: string, status: ProjectStatus) {
|
||||||
|
const last = await prisma.project.findFirst({
|
||||||
|
where: { ownerId, status },
|
||||||
|
orderBy: { position: "desc" },
|
||||||
|
select: { position: true },
|
||||||
|
});
|
||||||
|
return (last?.position ?? -1) + 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { APP_NAME } from "@/lib/config";
|
||||||
|
import { exportFileName } from "@/lib/export/format";
|
||||||
|
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
|
||||||
|
import { jsonError } from "@/lib/security/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Alle eigenen Projekte als JSON zum Herunterladen. */
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const projects = await loadProjectsForExport(auth.user.id);
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildExportFile(projects);
|
||||||
|
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(payload, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { recordActivity } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
exportFileSchema,
|
||||||
|
repositoriesOf,
|
||||||
|
type ExportedRepository,
|
||||||
|
} from "@/lib/export/format";
|
||||||
|
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import {
|
||||||
|
IMPORT_MAX_BODY_BYTES,
|
||||||
|
clientIp,
|
||||||
|
jsonError,
|
||||||
|
readJsonBody,
|
||||||
|
tooManyRequests,
|
||||||
|
} from "@/lib/security/request";
|
||||||
|
import { slugify } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
data: z.unknown(),
|
||||||
|
/**
|
||||||
|
* skip – vorhandene Projekte unveraendert lassen
|
||||||
|
* rename – zusaetzlich anlegen, mit freiem Slug
|
||||||
|
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
|
||||||
|
*/
|
||||||
|
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
|
||||||
|
/** Nur pruefen und berichten, nichts schreiben. */
|
||||||
|
dryRun: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
|
||||||
|
|
||||||
|
interface ItemResult {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
action: Action;
|
||||||
|
/** Bei "rename" der tatsaechlich vergebene Slug. */
|
||||||
|
newSlug?: string;
|
||||||
|
notes: number;
|
||||||
|
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
|
||||||
|
connection: "matched" | "missing" | "none";
|
||||||
|
connectionName?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`import:${clientIp(request)}`,
|
||||||
|
LIMITS.mutation.limit,
|
||||||
|
LIMITS.mutation.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const outer = requestSchema.safeParse(body.data);
|
||||||
|
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
|
||||||
|
|
||||||
|
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
|
||||||
|
// damit passiert.
|
||||||
|
const parsed = exportFileSchema.safeParse(outer.data.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const first = parsed.error.issues[0];
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Die Datei passt nicht zum erwarteten Format. " +
|
||||||
|
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { strategy, dryRun } = outer.data;
|
||||||
|
const file = parsed.data;
|
||||||
|
|
||||||
|
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
|
||||||
|
const connections = await prisma.gitConnection.findMany({
|
||||||
|
where: { ownerId: auth.user.id },
|
||||||
|
select: { id: true, name: true, provider: true, baseUrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
function matchConnection(described: ExportedRepository["connection"]) {
|
||||||
|
if (!described) return null;
|
||||||
|
const byName = connections.find(
|
||||||
|
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (byName) return byName;
|
||||||
|
return (
|
||||||
|
connections.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.provider === described.provider &&
|
||||||
|
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
|
||||||
|
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: ItemResult[] = [];
|
||||||
|
|
||||||
|
for (const incoming of file.projects) {
|
||||||
|
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
||||||
|
|
||||||
|
// Version 1 kannte ein Repository je Projekt und schrieb es flach; seit
|
||||||
|
// Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied.
|
||||||
|
const eingehendeRepos = repositoriesOf(incoming);
|
||||||
|
const repos = eingehendeRepos.map((repo, index) => ({
|
||||||
|
label: repo.label ?? null,
|
||||||
|
repoUrl: repo.repoUrl ?? null,
|
||||||
|
repoOwner: repo.repoOwner ?? null,
|
||||||
|
repoName: repo.repoName ?? null,
|
||||||
|
autoSync: repo.autoSync ?? true,
|
||||||
|
connectionId: matchConnection(repo.connection ?? null)?.id ?? null,
|
||||||
|
position: index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene
|
||||||
|
// Verbindung fehlt – welche, steht in der Datei.
|
||||||
|
const beschrieben = eingehendeRepos.filter((repo) => repo.connection);
|
||||||
|
const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null));
|
||||||
|
const connectionState: ItemResult["connection"] =
|
||||||
|
beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched";
|
||||||
|
|
||||||
|
const existing = await prisma.project.findFirst({
|
||||||
|
where: { slug, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const base: ItemResult = {
|
||||||
|
name: incoming.name,
|
||||||
|
slug,
|
||||||
|
action: "create",
|
||||||
|
notes: incoming.notes.length,
|
||||||
|
connection: connectionState,
|
||||||
|
connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing && strategy === "skip") {
|
||||||
|
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
results.push({
|
||||||
|
...base,
|
||||||
|
action,
|
||||||
|
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
|
||||||
|
...(action === "overwrite"
|
||||||
|
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
|
||||||
|
position: await nextProjectPosition(auth.user.id, incoming.status),
|
||||||
|
name: incoming.name,
|
||||||
|
summary: incoming.summary ?? null,
|
||||||
|
description: incoming.description ?? null,
|
||||||
|
status: incoming.status,
|
||||||
|
priority: incoming.priority,
|
||||||
|
progress: incoming.progress,
|
||||||
|
accent: incoming.accent,
|
||||||
|
tags: incoming.tags,
|
||||||
|
favorite: incoming.favorite,
|
||||||
|
progressFromTasks: incoming.progressFromTasks,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks = incoming.tasks.map((task, index) => ({
|
||||||
|
title: task.title,
|
||||||
|
description: task.description ?? null,
|
||||||
|
status: task.status,
|
||||||
|
// Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr.
|
||||||
|
position: task.position ?? index,
|
||||||
|
dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate))
|
||||||
|
? new Date(task.dueDate)
|
||||||
|
: null,
|
||||||
|
labels: task.labels,
|
||||||
|
doneAt: task.status === "DONE" ? new Date() : null,
|
||||||
|
...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt))
|
||||||
|
? { createdAt: new Date(task.createdAt) }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notes = incoming.notes.map((note) => ({
|
||||||
|
title: note.title ?? null,
|
||||||
|
content: note.content,
|
||||||
|
pinned: note.pinned ?? false,
|
||||||
|
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
|
||||||
|
? { createdAt: new Date(note.createdAt) }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existing && action === "overwrite") {
|
||||||
|
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
|
||||||
|
// erneuter Import erzeugt keine Dubletten.
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
||||||
|
prisma.task.deleteMany({ where: { projectId: existing.id } }),
|
||||||
|
// Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der
|
||||||
|
// gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten
|
||||||
|
// Abgleich zurueck.
|
||||||
|
prisma.repository.deleteMany({ where: { projectId: existing.id } }),
|
||||||
|
prisma.project.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
notes: { create: notes },
|
||||||
|
tasks: { create: tasks },
|
||||||
|
repositories: { create: repos },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
results.push({ ...base, action: "overwrite" });
|
||||||
|
} else {
|
||||||
|
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
|
||||||
|
const angelegt = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
slug: finalSlug,
|
||||||
|
notes: { create: notes },
|
||||||
|
tasks: { create: tasks },
|
||||||
|
repositories: { create: repos },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
|
||||||
|
// wenigstens fest, woher das Projekt stammt.
|
||||||
|
await recordActivity({
|
||||||
|
projectId: angelegt.id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "PROJECT_CREATED",
|
||||||
|
summary: "Aus einer Exportdatei importiert",
|
||||||
|
meta: { notes: notes.length, tasks: tasks.length },
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
...base,
|
||||||
|
action: existing ? "rename" : "create",
|
||||||
|
...(existing ? { newSlug: finalSlug } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
...base,
|
||||||
|
action: "error",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: results.length,
|
||||||
|
created: results.filter((entry) => entry.action === "create").length,
|
||||||
|
renamed: results.filter((entry) => entry.action === "rename").length,
|
||||||
|
overwritten: results.filter((entry) => entry.action === "overwrite").length,
|
||||||
|
skipped: results.filter((entry) => entry.action === "skip").length,
|
||||||
|
errors: results.filter((entry) => entry.action === "error").length,
|
||||||
|
missingConnections: results.filter((entry) => entry.connection === "missing").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ dryRun, strategy, summary, results });
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Prisma, ProjectStatus } from "@prisma/client";
|
||||||
|
import { describeStatus, recordActivities } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { formatZodError, projectReorderSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Reihenfolge im Brett nach dem Ziehen.
|
||||||
|
*
|
||||||
|
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
|
||||||
|
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
|
||||||
|
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
|
||||||
|
* anzurichten.
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`mutate:${clientIp(request)}`,
|
||||||
|
LIMITS.mutation.limit,
|
||||||
|
LIMITS.mutation.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = projectReorderSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
|
||||||
|
const requestedIds = Object.values(columns).flat();
|
||||||
|
|
||||||
|
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
|
||||||
|
// untergeschobene IDs verschieben.
|
||||||
|
const owned = await prisma.project.findMany({
|
||||||
|
where: { ownerId: auth.user.id, id: { in: requestedIds } },
|
||||||
|
select: { id: true, status: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((project) => project.id));
|
||||||
|
const vorher = new Map(owned.map((project) => [project.id, project]));
|
||||||
|
|
||||||
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
|
const entries: Parameters<typeof recordActivities>[0] = [];
|
||||||
|
|
||||||
|
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
|
||||||
|
ids.forEach((projectId, index) => {
|
||||||
|
const before = vorher.get(projectId);
|
||||||
|
if (!ownedIds.has(projectId) || !before) return;
|
||||||
|
|
||||||
|
const gewandert = before.status !== status;
|
||||||
|
|
||||||
|
updates.push(
|
||||||
|
prisma.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
position: index,
|
||||||
|
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
|
||||||
|
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
|
||||||
|
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
|
||||||
|
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
|
||||||
|
...(gewandert ? {} : { updatedAt: before.updatedAt }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
|
||||||
|
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
|
||||||
|
if (gewandert) {
|
||||||
|
entries.push({
|
||||||
|
projectId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "STATUS_CHANGED",
|
||||||
|
summary: describeStatus(before.status, status),
|
||||||
|
meta: { from: before.status, to: status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await prisma.$transaction(updates);
|
||||||
|
await recordActivities(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
updated: updates.length,
|
||||||
|
ignored: requestedIds.length - updates.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||||
|
import { applyLeadingRepo } from "@/lib/repositories";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { statusOf } from "@/lib/status";
|
||||||
|
import { formatZodError, projectCreateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const query = searchParams.get("q");
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
// Die Besitzerbedingung steht immer zuerst und ist nicht optional.
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
...(status ? { status: status as never } : {}),
|
||||||
|
...(query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: "insensitive" as const } },
|
||||||
|
{ summary: { contains: query, mode: "insensitive" as const } },
|
||||||
|
{ tags: { has: query.toLowerCase() } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: projectListInclude,
|
||||||
|
orderBy: [{ favorite: "desc" }, { updatedAt: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const limited = rateLimit(
|
||||||
|
`mutate:${clientIp(request)}`,
|
||||||
|
LIMITS.mutation.limit,
|
||||||
|
LIMITS.mutation.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = projectCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
// Eine fremde Verbindung darf nicht untergeschoben werden.
|
||||||
|
if (data.connectionId) {
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id: data.connectionId, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die
|
||||||
|
// Oberflaeche schon in die Felder uebernommen, damit man sie vor dem
|
||||||
|
// Speichern noch sieht und aendern kann.
|
||||||
|
const vorlage = data.templateId
|
||||||
|
? await prisma.projectTemplate.findFirst({
|
||||||
|
where: { id: data.templateId, ownerId: auth.user.id },
|
||||||
|
select: { name: true, notes: true, tasks: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404);
|
||||||
|
|
||||||
|
const notes = templateNotes(vorlage?.notes);
|
||||||
|
const tasks = templateTasks(vorlage?.tasks);
|
||||||
|
|
||||||
|
const angelegt = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
name: data.name,
|
||||||
|
slug: await uniqueSlug(data.name, auth.user.id),
|
||||||
|
summary: data.summary ?? null,
|
||||||
|
description: data.description ?? null,
|
||||||
|
status: data.status,
|
||||||
|
priority: data.priority,
|
||||||
|
progress: data.progress,
|
||||||
|
accent: data.accent,
|
||||||
|
tags: data.tags,
|
||||||
|
favorite: data.favorite,
|
||||||
|
progressFromTasks: data.progressFromTasks,
|
||||||
|
// Hinten an die Statusspalte des Bretts.
|
||||||
|
position: await nextProjectPosition(auth.user.id, data.status),
|
||||||
|
notes: { create: notes },
|
||||||
|
tasks: { create: tasks },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen.
|
||||||
|
await applyLeadingRepo(angelegt.id, {
|
||||||
|
repoUrl: data.repoUrl,
|
||||||
|
repoOwner: data.repoOwner,
|
||||||
|
repoName: data.repoName,
|
||||||
|
connectionId: data.connectionId,
|
||||||
|
autoSync: data.autoSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: angelegt.id },
|
||||||
|
include: projectListInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "PROJECT_CREATED",
|
||||||
|
summary: vorlage
|
||||||
|
? `Aus Vorlage „${vorlage.name}“ angelegt`
|
||||||
|
: data.status === "IDEA"
|
||||||
|
? "Als Idee angelegt"
|
||||||
|
: `Angelegt mit Status „${statusOf(data.status).label}“`,
|
||||||
|
meta: {
|
||||||
|
status: data.status,
|
||||||
|
...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript
|
||||||
|
* unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen
|
||||||
|
* Eintrag auslassen als am Anlegen scheitern.
|
||||||
|
*/
|
||||||
|
function templateNotes(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
||||||
|
.filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0)
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((entry) => ({
|
||||||
|
title: typeof entry.title === "string" ? entry.title : null,
|
||||||
|
content: entry.content as string,
|
||||||
|
pinned: entry.pinned === true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateTasks(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
||||||
|
.filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0)
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((entry, index) => ({
|
||||||
|
title: entry.title as string,
|
||||||
|
description: typeof entry.description === "string" ? entry.description : null,
|
||||||
|
status: (["TODO", "DOING", "BLOCKED"] as const).includes(
|
||||||
|
entry.status as "TODO" | "DOING" | "BLOCKED",
|
||||||
|
)
|
||||||
|
? (entry.status as "TODO" | "DOING" | "BLOCKED")
|
||||||
|
: ("TODO" as const),
|
||||||
|
// Reihenfolge wie in der Vorlage.
|
||||||
|
position: index,
|
||||||
|
labels: Array.isArray(entry.labels)
|
||||||
|
? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8)
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user