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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 3bfed6d9f6 feat(notify): Benachrichtigungen ueber ntfy, Webhook und E-Mail
Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten
dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen.

Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer
Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt
ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei
jedem stuendlichen Durchlauf. Zwei Feinheiten:

- Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede
  bereits vorhandene Version eine "Neuigkeit".
- Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst
  kaeme beim ersten Ziel die ganze Vergangenheit hinterher.

Zustellwege:
- ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem
  Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch
  Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das
  Sinnbild liefert ohnehin der Tags-Header.
- Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf
  per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann.
- E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist.

Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen
deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects
werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere.
Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf
den Abgleich nicht scheitern lassen.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 6e63fec49b test: Ende-zu-Ende-Tests mit Playwright
13 Tests gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 -
also durch Middleware, Authentifizierung, Validierung und Datenbank hindurch.
Genau die Schicht, die Unit-Tests nicht erreichen. Laufzeit rund 20 Sekunden.

api.spec.ts prueft, was bisher nur von Hand geprueft war:
- Einrichtung sperrt sich nach dem ersten Konto
- ohne Anmeldung liefert jeder Endpunkt 401
- fremdes Origin und falscher Content-Type werden abgewiesen
- SSRF-Sperren greifen
- Datentrennung: ein zweites Konto bekommt auf fremde Projekte, Aufgaben und
  Adminwege durchgaengig 404 beziehungsweise 403, das Original bleibt
  unveraendert
- Aufgaben: Reihenfolge, abgeleiteter Fortschritt, untergeschobene IDs
- Export ohne Geheimnisse, Import je Strategie

ui.spec.ts geht den Weg eines Menschen: anmelden, Projekt anlegen, Aufgaben
anlegen und ziehen, suchen, abmelden. Das Ziehen wird nach einem Neuladen
erneut geprueft - sonst waere nur die Anzeige getestet, nicht das Speichern.

Zwei Vorkehrungen:
- global-setup.ts bricht ab, wenn der Datenbankname nicht "test" enthaelt,
  und zwar vor dem TRUNCATE. Der teuerste denkbare Fehler waere eine
  Testreihe, die die Produktivdaten leert.
- Playwright startet den Server selbst mit eigener DATABASE_URL und eigenem
  Port; eine laufende Instanz auf 3000 stoert nicht.

Beim Schreiben zeigte sich, dass synthetische Zieh-Gesten eine Pause vor dem
Loslassen brauchen - dnd-kit wertet die Bewegung in einem eigenen Frame aus
und faende sonst kein Ziel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 3ce866da2c docs: CLAUDE.md mit Projektkontext
Damit eine neue Sitzung in diesem Ordner nicht bei null anfaengt: die
Befehle, die nicht verhandelbaren Regeln (Datentrennung, Pruefung in jedem
Endpunkt, keine Geheimnisse nach aussen) und vor allem die Fallstricke, die
in diesem Projekt schon zugeschlagen haben - node-Module in
Client-Komponenten, instrumentation.ts im Edge-Bundle, CSS-Klassen ausserhalb
von @layer, Overlays in Karten mit backdrop-filter.

Dazu der Hinweis, dass Migrationen hier ueber "migrate diff" entstehen, weil
"migrate dev" interaktiv ist, und die Erinnerung, bei Oberflaechenaenderungen
tatsaechlich hinzusehen statt es zu behaupten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 5b40d5e4ff feat(api): progressFromTasks im Projekt-Endpunkt
Der Schalter laesst sich ueber PATCH /api/projects/:id setzen. Beim
Einschalten wird der abgeleitete Wert sofort berechnet, damit er nicht bis
zur naechsten Aufgabenaenderung falsch steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:30:04 +02:00
florian.wildeandClaude Opus 5 c50fa811c5 docs: Aufgabenbrett dokumentieren
FEATURES bekommt ein eigenes Kapitel: die vier Spalten, das Ziehen mit
Sortierung, Faelligkeiten, der abgeleitete Fortschritt und die Erklaerung,
warum die Reihenfolge als vollstaendige Liste je Spalte gespeichert wird.

ROADMAP: "Aufgaben und Checklisten" wandert zu den erledigten Punkten. Neu
unter den Kleinigkeiten stehen wiederkehrende Aufgaben und das Uebernehmen
offener Git-Issues als Aufgaben - beides ergibt sich erst jetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:51 +02:00
florian.wildeandClaude Opus 5 e40a3bd62e fix: Warnung beim Einlesen der Versionsnummer beseitigen
Der benannte Import aus der package.json loeste in Webpack die Warnung
"Should not import the named export 'version'" aus. Jetzt als Standardimport.
Version auf 0.5.0 gehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 79c4bd1967 feat(export): Aufgaben in Export, Import und Sicherung aufnehmen
Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import
wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr.
Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt
zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt.

Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten
gegliedert und als Checkliste geschrieben - so bleibt der Stand auch
ausserhalb von Nebula lesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 0212dad2bf feat(ui): Aufgabenbrett auf der Projektseite
Vier Spalten, Karten per Ziehen verschiebbar - zwischen den Spalten und
innerhalb einer Spalte. Waehrend des Ziehens fuehrt der oertliche Zustand,
danach wird die neue Ordnung als Ganzes gespeichert; scheitert das, springt
das Brett auf den Serverstand zurueck.

Details:
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den
  Dialog oeffnet.
- Der Spaltenwechsel passiert schon beim Darueberziehen, nicht erst beim
  Ablegen - sonst wirkt das Brett traege.
- Der Serverstand wird nur uebernommen, wenn gerade nicht gezogen wird,
  sonst spraenge die Karte mitten in der Bewegung zurueck.
- Faelligkeiten faerben sich: ueberfaellig rot, heute gelb, demnaechst
  gedaempft.

Ist "Fortschritt aus Aufgaben" eingeschaltet, wird der Regler gesperrt statt
einen Wert anzubieten, der beim naechsten Aufgabenwechsel ueberschrieben
wuerde - im Dialog wie im Kopfbereich. Die Projektkarte zeigt das Verhaeltnis
als "3/8".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 72bcf824ce feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge
Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 298da72b6d docs: v0.3.0 und v0.4.0 dokumentieren, Roadmap fortschreiben
FEATURES beschreibt Markdown, Volltextsuche, PR- und Issue-Listen, den
CI-Status samt Herkunft je Git-System, die Aktivitaetskurve inklusive ihrer
Grenze, das Kanban-Board und das Testfundament.

ROADMAP: sieben Punkte wandern zu den erledigten. Ganz oben steht jetzt der
Ausbau der Tests auf Datenbank und Browser - das Fundament steht, die
Schicht darueber fehlt und ich pruefe weiterhin von Hand. Danach
Benachrichtigungen, die von CI-Status und Abgleich nun profitieren wuerden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 92285b11fc fix: Versionsnummer aus einer einzigen Quelle
Die Fusszeile zeigte v0.2.0, die package.json stand auf 0.1.0 - beim
Auth-Feature hatte ich nur die Anzeige hochgezaehlt. Jetzt kommt die Version
aus der package.json, kann also nicht mehr auseinanderlaufen. Stand: 0.4.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 e55dab6473 feat(ui): Kanban-Board mit Ziehen und Ablegen
Dritte Ansicht neben Raster und Gruppierung: eine Spalte je Status, Karten
per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und erst danach
gespeichert; scheitert das Speichern, springt die Karte zurueck.

Details, die den Unterschied machen:
- Gezogen wird am Griff links, nicht an der ganzen Karte - so bleibt der
  Titel ein normaler Link.
- Das Ziehen startet erst nach sechs Pixeln Bewegung, sonst liesse sich die
  Karte nicht mehr anklicken. Auf Touchgeraeten nach kurzem Halten.
- Die mitgezogene Karte haengt in einem DragOverlay am Cursor, das Original
  bleibt blass stehen - sonst wuerde die Spalte unter dem Finger springen.
- Bestaetigt der Server die Verschiebung, wird die oertliche Ausnahme wieder
  entfernt, damit sie nicht veraltet.

Die Reihenfolge innerhalb einer Spalte folgt der gewaehlten Sortierung.
Frei sortieren hiesse, eine Position je Projekt zu speichern - bewusst
nicht gebaut.

Nachgewiesen im Browser: Karte aus "Idee" nach "Fertig" gezogen, Zielspalte
hebt sich hervor und zeigt "Hier ablegen", danach steht das Projekt
serverseitig auf DONE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 de8f13f242 test: Fundament mit Vitest fuer die reine Logik
51 Tests, unter einer Sekunde, ohne DOM, Datenbank oder laufenden Server.
Abgedeckt sind die Stellen, an denen ein Fehler teuer waere:

- Slug-Erzeugung mit Umlauten, Tag-Aufbereitung, Textkuerzung
- URL-Zerlegung fuer https, SSH, verschachtelte GitLab-Gruppen und
  Oberflaechenpfade; Provider-Erkennung; Bildung der Aktivitaetskurve
- SSRF-Sperrlisten: Loopback, Link-Local samt Metadaten-Endpunkt, CGNAT,
  Multicast, IPv4-gemappte IPv6-Adressen; Formpruefung; alle drei
  Umgebungsschalter
- Passwoerter: scrypt-Rundlauf, unterschiedliches Salz je Hash, Ablageformat,
  Verhalten bei kaputten Hashes, Unicode-Normalisierung, Passwortregeln

Bewusst ohne Datenbank- und Browsertests - die brauchen Infrastruktur und
gehoeren in eine Pipeline. Steht als naechster Punkt in der ROADMAP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 03ce74faa2 feat(git): offene PRs und Issues, CI-Status und Commit-Aktivitaet
Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit.

Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel,
Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der
Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert.

CI-Status des letzten Laufs auf dem Default-Branch:
- GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und
  werden auf einen Wert zusammengefuehrt
- GitLab ueber pipelines
- Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und
  externe Systeme wie Woodpecker gleichermassen ab - ein reiner
  Actions-Endpunkt existiert auf aelteren Instanzen nicht.

Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten
Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene
Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei
GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten.

Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die
letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null
gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den
tatsaechlich belegten Zeitraum gekuerzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 4bb600bb06 feat(search): Volltextsuche ueber Notizen
Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
Name, Kurzbeschreibung, Tags und Repo-Pfad ab. Notizen liegen dort nicht vor
- sie mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb ein eigener
Endpunkt, der Postgres mit seiner deutschen Textsuche arbeiten laesst.

- websearch_to_tsquery versteht Eingaben wie "docker -compose" und wirft bei
  Unsinn keinen Fehler
- ts_headline liefert den Ausschnitt gleich mit; die Hervorhebung kommt als
  [[…]] zurueck, damit die Oberflaeche kein HTML aus der Datenbank einbetten
  muss
- deutsche Stammformen greifen: "Webhook" findet auch "Webhooks"
- entprellt ab zwei Zeichen, laufende Abfragen werden abgebrochen

Dazu ein GIN-Index auf dem Suchausdruck. Prisma kann Ausdrucksindizes im
Schema nicht abbilden - nachgeprueft: "migrate diff" laesst ihn in Ruhe und
schlaegt kein DROP vor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:03:55 +02:00
76 changed files with 5782 additions and 150 deletions
+15
View File
@@ -56,6 +56,21 @@ APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen. # Lebensdauer einer Anmeldung in Tagen.
SESSION_TTL_DAYS="30" 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 ────────────────────────────────── # ── Ausgehende Git-Zugriffe ──────────────────────────────────
# Timeout fuer Git-API-Aufrufe in Millisekunden # Timeout fuer Git-API-Aufrufe in Millisekunden
GIT_API_TIMEOUT_MS="15000" GIT_API_TIMEOUT_MS="15000"
+78
View File
@@ -0,0 +1,78 @@
# Prueft jeden Push und jeden Pull Request.
#
# Die Datei liegt unter .gitea/workflows und wird von Gitea Actions gelesen.
# Die Syntax ist mit GitHub Actions kompatibel – wer das Repo dorthin
# spiegelt, kopiert sie nach .github/workflows.
#
# Voraussetzung: In der Gitea-Instanz muessen Actions aktiviert sein
# (app.ini: [actions] ENABLED = true) und mindestens ein Runner registriert.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: nebula
POSTGRES_PASSWORD: nebula
# Der Name muss "test" enthalten – global-setup.ts leert die
# Datenbank und weigert sich bei allem anderen.
POSTGRES_DB: nebula_e2e_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U nebula"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
E2E_DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
# Nur fuer den Lauf – keine echten Daten im Spiel.
APP_SECRET: ci-lauf-schluessel-nur-fuer-die-pipeline-0123456789
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Abhaengigkeiten
run: npm ci
- name: Typen pruefen
run: npx tsc --noEmit
- name: Unit-Tests
run: npm test
- name: Browser fuer die Oberflaechentests
run: npx playwright install --with-deps chromium
# Baut zuerst und startet den Server dann selbst (siehe
# playwright.config.ts, webServer).
- name: Ende-zu-Ende-Tests
run: npm run test:e2e
- name: Bericht bei Fehlschlag sichern
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-bericht
path: |
playwright-report/
test-results/
retention-days: 7
+6
View File
@@ -46,6 +46,12 @@ yarn-error.log*
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz # Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
/backups /backups
# Playwright
/test-results
/playwright-report
/blob-report
/playwright/.cache
# editors # editors
.idea/ .idea/
.vscode/* .vscode/*
+104
View File
@@ -0,0 +1,104 @@
# 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)
## Womit man arbeitet
```bash
docker compose up -d db # Postgres, muss laufen
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
npx prisma migrate diff --from-schema-datasource prisma/schema.prisma \
--to-schema-datamodel prisma/schema.prisma --script --output prisma/migrations/<stempel>_<name>/migration.sql
npx prisma migrate deploy
```
**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).
## 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.
**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.
+196 -3
View File
@@ -107,10 +107,28 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Zwei Ansichten**: freies Raster oder nach Status gruppierte Abschnitte. - **Drei Ansichten**: freies Raster, 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. - **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche. - **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
### 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.
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut.
### Projektkarte auf einen Blick ### Projektkarte auf einen Blick
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge · Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
@@ -119,9 +137,46 @@ 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.
### 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.
---
## 3. Notizen ## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text. - **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. - **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach. - **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung. - **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
@@ -164,12 +219,28 @@ Firmen-Gitea nebeneinander.
| --- | --- | | --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität | | Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests | | 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 | | 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 | | Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15, mit Kurz-SHA, Nachricht, Autor, Datum und Link | | 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 |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten | | 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 - **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar. ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
@@ -251,6 +322,75 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
--- ---
## 4b. Benachrichtigungen
Einzurichten unter *Mein Konto → Benachrichtigungen*, pro Konto und beliebig viele Ziele.
### Anlässe
| Anlass | Wann |
| --- | --- |
| ⚠️ **Abgleich fehlgeschlagen** | Das Git-System war nicht erreichbar oder hat abgelehnt |
| 🔴 **CI rot** | Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen |
| 🏷️ **Neues Release** | Ein neuer Tag oder ein neues Release ist aufgetaucht |
| 🕸️ **Projekt steht still** | Seit längerem kein Commit — Schwelle einstellbar, Vorgabe 60 Tage |
### Nur bei Änderung
Der entscheidende Punkt: Gemeldet wird **ausschließlich, wenn sich etwas ändert**
gegenüber dem, worüber zuletzt benachrichtigt wurde. Ein Repository mit abgelaufenem
Token meldet sich also **einmal**, nicht bei jedem stündlichen Durchlauf. Wird der Fehler
ein anderer, kommt eine neue Meldung; ist er behoben und tritt später erneut auf, ebenso.
Zwei Feinheiten:
- **Beim allerersten Abgleich** eines Repositories wird kein Release gemeldet — sonst
wäre jede bereits vorhandene Version eine „Neuigkeit".
- Der Merkstand wird auch dann fortgeschrieben, wenn **kein** Ziel eingerichtet ist. Sonst
käme beim ersten eingerichteten Ziel die ganze Vergangenheit hinterher.
### Stillstand
Der einzige Anlass, der an keinem Ereignis hängt — es passiert ja gerade nichts. Deshalb
ein eigener Durchlauf, **höchstens einmal am Tag**, unabhängig vom automatischen Abgleich.
Hört niemand auf diesen Anlass, läuft die Prüfung gar nicht erst.
Maßstab ist ausschließlich der **letzte Commit** im verknüpften Repository. Das
Änderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und würde den
Stillstand immer wieder zurücksetzen. Projekte **ohne Repository** bleiben deshalb außen
vor, ebenso fertige und archivierte.
Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit kam und es
seitdem wieder still geworden ist — ein Projekt, das aufwacht und wieder einschläft, meldet
sich also ein zweites Mal.
Die Schwelle setzt ein Administrator unter *Admin-Einstellungen → Stillstand* (14 Tage bis
ein Jahr). Eine Änderung wirkt sofort, nicht erst nach 24 Stunden.
### Zustellwege
| Weg | Details |
| --- | --- |
| **ntfy** | Vollständige Themen-URL. Titel, Priorität und Symbol als Kopfzeilen; optionales Bearer-Token für geschützte Themen. Läuft auch gegen ein selbst gehostetes ntfy im eigenen Netz. |
| **Webhook** | JSON per POST mit Anlass, Titel, Text, Projektbezug und Tieflink. Mit hinterlegtem Geheimnis wird der Rumpf per HMAC-SHA256 signiert (`X-Nebula-Signature`), damit der Empfänger die Herkunft prüfen kann. |
| **E-Mail** | Über SMTP. Steht erst zur Verfügung, wenn `SMTP_HOST` in der `.env` gesetzt ist — sonst ist die Auswahl in der Oberfläche gesperrt und erklärt, warum. |
Jedes Ziel lässt sich einzeln **testen** und **pausieren**; Ergebnis und Zeitpunkt der
letzten Zustellung stehen an der Karte.
### Absicherung
- Zieladressen sind Benutzereingaben, die der Server abruft — sie laufen deshalb durch
denselben **SSRF-Schutz** wie die Git-Verbindungen. Ein ntfy im eigenen Netz bleibt
erlaubt, Loopback und Cloud-Metadaten nicht.
- Redirects werden **nicht verfolgt**: ein Umleitungsziel wäre nicht geprüft.
- Geheimnisse liegen AES-256-GCM-verschlüsselt und verlassen den Server nie; die
Oberfläche zeigt nur einen Hinweis wie `test…mnis`.
- Eine fehlgeschlagene Zustellung lässt den Abgleich nicht scheitern — der Fehler landet
am Ziel, nicht im Sync.
---
## 5. Oberfläche ## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines - **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
@@ -277,6 +417,8 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges | | `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer | | `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer | | `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) | | `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
@@ -328,7 +470,58 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`. - **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an. - **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 6.4 API ### 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. Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health.
Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
+14
View File
@@ -127,6 +127,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer | | `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 `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 Docker-Image lässt sich also ohne Neubau umbenennen oder unter einer anderen Adresse
@@ -298,7 +300,12 @@ nebula/
| `npm run db:seed` | Beispielprojekte anlegen | | `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen | | `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) | | `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 run typecheck` | TypeScript prüfen |
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten | | `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen | | `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` | | `npm run docker:tools` | Adminer auf `http://localhost:8080` |
@@ -332,8 +339,15 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) | | `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau | | `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) | | `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 | | `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen | | `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts | | `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen | | `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen | | `GET` `POST` | `/api/connections` | Git-Verbindungen |
+24 -68
View File
@@ -11,101 +11,48 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
## 1 · Als Nächstes empfohlen ## 1 · Als Nächstes empfohlen
### 1.1 Markdown in Beschreibung und Notizen `S` ### 1.1 Webhooks statt Polling `M`
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
werden klickbar.
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
### 1.2 Volltextsuche über Notizen `S`
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
zusätzlichen Dienst.
### 1.3 Kanban-Board mit Drag & Drop `M`
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
bleibt die Listenansicht.
---
## 2 · Danach
### 2.1 Offene Pull Requests und Issues als Liste `S`
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
nützlicher — die Daten holen wir ohnehin schon ab.
### 2.2 CI-Status je Branch `M`
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
Endpunkt und einen zusätzlichen Token-Scope.
### 2.3 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung. erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
### 2.4 Commit-Aktivität als Verlauf `S` ---
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist ## 2 · Danach
da, es fehlt nur die Darstellung.
### 2.5 Aufgaben und Checklisten `M` ### 2.1 Commit-Aktivität als Heatmap `S`
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers. wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.6 Benachrichtigungen `M` ### 2.2 Kanban mit freier Reihenfolge `M`
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen. Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem Frei sortieren hieße, eine Position je Projekt zu speichern.
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
--- ---
## 3 · Größere Brocken ## 3 · Größere Brocken
### 3.1 Automatisierte Tests und CI `L` ### 3.1 Mehrere Repositories je Projekt `M`
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
Browser-Screenshots. Das gehört in eine Pipeline:
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
Backoff-Berechnung
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
Datentrennung
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
länger lebt: er macht alles Weitere billiger und sicherer.
### 3.2 Mehrere Repositories je Projekt `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
und Anpassungen in der Anzeige. und Anpassungen in der Anzeige.
### 3.3 Zwei-Faktor per TOTP `M` ### 3.2 Zwei-Faktor per TOTP `M`
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
erreichbar ist und nicht jedes Gerät Passkeys kann. erreichbar ist und nicht jedes Gerät Passkeys kann.
### 3.4 Passwort-Zurücksetzen per E-Mail `M` ### 3.3 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.5 Mehrsprachigkeit `L` ### 3.4 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst. Voraussetzung, falls du das Projekt veröffentlichen willst.
@@ -117,14 +64,17 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion | | Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal | | Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert | | Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Aufgaben aus Git-Issues übernehmen | `M` | Offene Issues als Aufgaben einhängen |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar | | PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste | | Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 | | `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
@@ -156,6 +106,12 @@ Damit klar ist, was **nicht** kommt und warum:
| Docker- und Standalone-Betrieb | ✓ | | Docker- und Standalone-Betrieb | ✓ |
| Automatischer Abgleich mit Zeitplan | ✓ | | Automatischer Abgleich mit Zeitplan | ✓ |
| Export, Import und Sicherung | ✓ | | 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 | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+7
View File
@@ -59,6 +59,13 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000} 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_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false}
GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false} GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false}
GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-} GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-}
+227
View File
@@ -0,0 +1,227 @@
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("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;
// 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);
});
+62
View File
@@ -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();
}
}
+109
View File
@@ -0,0 +1,109 @@
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);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
// 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("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/);
});
+86 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.1.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "0.1.0", "version": "0.6.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -17,6 +17,7 @@
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -25,8 +26,10 @@
"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/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"prisma": "^6.8.2", "prisma": "^6.8.2",
@@ -1513,6 +1516,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -2302,6 +2321,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -4373,6 +4402,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.8", "version": "0.6.8",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
@@ -4489,6 +4527,52 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+8 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.4.0", "version": "0.8.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": {
@@ -25,7 +25,10 @@
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "npm run build && playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm test && npm run test:e2e"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
@@ -39,6 +42,7 @@
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -47,8 +51,10 @@
"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/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"prisma": "^6.8.2", "prisma": "^6.8.2",
+63
View File
@@ -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,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);
+115
View File
@@ -33,6 +33,33 @@ enum GitProvider {
GITEA GITEA
} }
/// Zustellwege fuer Benachrichtigungen.
enum NotificationChannel {
WEBHOOK
NTFY
EMAIL
}
/// Anlaesse, ueber die benachrichtigt werden kann.
enum NotificationEvent {
/// Ein Abgleich mit dem Git-System ist fehlgeschlagen.
SYNC_FAILED
/// Der letzte CI-Lauf auf dem Default-Branch ist rot.
CI_FAILED
/// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht.
NEW_RELEASE
/// Seit laengerem kein Commit mehr – der sanfte Stups.
PROJECT_STALE
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat /// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart. /// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole { enum UserRole {
@@ -70,6 +97,7 @@ model User {
credentials Credential[] credentials Credential[]
projects Project[] projects Project[]
connections GitConnection[] connections GitConnection[]
notificationTargets NotificationTarget[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -155,6 +183,12 @@ model AppSettings {
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json? 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 updatedAt DateTime @updatedAt
} }
@@ -237,7 +271,22 @@ model Project {
ownerId String? ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) 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)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime?
notes Note[] notes Note[]
tasks Task[]
snapshot RepoSnapshot? snapshot RepoSnapshot?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -250,6 +299,60 @@ model Project {
@@index([ownerId]) @@index([ownerId])
} }
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
model NotificationTarget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
channel NotificationChannel
/// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse.
target String
/// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer
/// ntfy oder ein Signaturschluessel fuer den Webhook.
secretCipher String?
secretHint String?
/// Worueber dieses Ziel benachrichtigt werden will.
events NotificationEvent[] @default([])
isActive Boolean @default(true)
lastSentAt DateTime?
lastOk Boolean?
lastMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, status, position])
}
/// Freie Notizen zu einem Projekt. /// Freie Notizen zu einem Projekt.
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -303,6 +406,18 @@ model RepoSnapshot {
commits Json @default("[]") commits Json @default("[]")
releases Json @default("[]") releases Json @default("[]")
languages Json @default("{}") languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
pushedAt DateTime? pushedAt DateTime?
fetchedAt DateTime @default(now()) fetchedAt DateTime @default(now())
+27 -1
View File
@@ -1,5 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager"; 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 { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config"; import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -13,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AccountPage() { export default async function AccountPage() {
const user = await requireUserPage(); const user = await requireUserPage();
const [passkeys, sessions] = await Promise.all([ const [passkeys, sessions, targets] = await Promise.all([
prisma.credential.findMany({ prisma.credential.findMany({
where: { userId: user.id }, where: { userId: user.id },
select: { select: {
@@ -37,6 +42,22 @@ export default async function AccountPage() {
}, },
orderBy: { lastSeenAt: "desc" }, orderBy: { lastSeenAt: "desc" },
}), }),
prisma.notificationTarget.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
},
orderBy: { name: "asc" },
}),
]); ]);
const data: AccountData = { const data: AccountData = {
@@ -60,6 +81,11 @@ export default async function AccountPage() {
</header> </header>
<AccountManager data={data} /> <AccountManager data={data} />
<NotificationSection
targets={targets as unknown as NotificationTargetEntry[]}
emailAvailable={emailAvailable()}
/>
</div> </div>
); );
} }
+9 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() { export default async function AdminPage() {
const admin = await requireAdminPage(); const admin = await requireAdminPage();
const [settings, users, eligible] = await Promise.all([ const [settings, users, eligible, staleListeners] = await Promise.all([
getAppSettings(), getAppSettings(),
prisma.user.findMany({ prisma.user.findMany({
select: userSafeSelect, select: userSafeSelect,
@@ -36,6 +36,9 @@ export default async function AdminPage() {
], ],
}, },
}), }),
prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
}),
]); ]);
const data: AdminData = { const data: AdminData = {
@@ -54,6 +57,11 @@ export default async function AdminPage() {
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null, lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
eligible, eligible,
}, },
stale: {
afterDays: settings.staleAfterDays,
lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners,
},
}; };
return ( return (
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
prisma.project.findMany({ prisma.project.findMany({
include: { include: {
notes: { orderBy: { createdAt: "asc" } }, notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross. // Snapshots bleiben draussen: reproduzierbar und gross.
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
+6
View File
@@ -37,6 +37,7 @@ export async function PATCH(request: Request) {
autoSyncIntervalMinutes, autoSyncIntervalMinutes,
autoSyncActiveOnly, autoSyncActiveOnly,
autoSyncBatchSize, autoSyncBatchSize,
staleAfterDays,
} = parsed.data; } = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
@@ -57,6 +58,11 @@ export async function PATCH(request: Request) {
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}), ...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}), ...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}), ...(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({ const settings = await prisma.appSettings.upsert({
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { targetSafeSelect } from "@/lib/notify/types";
import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!existing) return jsonError("Ziel nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined) update.name = data.name;
if (data.events !== undefined) update.events = data.events;
if (data.isActive !== undefined) update.isActive = data.isActive;
if (data.target !== undefined) {
if (existing.channel !== "EMAIL") {
try {
await assertAllowedUrl(data.target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
update.target = data.target;
}
// Leeres Feld heisst "unveraendert", null heisst "entfernen".
if (data.secret !== undefined) {
if (data.secret) {
update.secretCipher = encryptSecret(data.secret);
update.secretHint = tokenHint(data.secret);
} else {
update.secretCipher = null;
update.secretHint = null;
}
}
try {
const target = await prisma.notificationTarget.update({
where: { id },
data: update,
select: targetSafeSelect,
});
return NextResponse.json({ target });
} catch {
return jsonError("Name bereits vergeben.", 409);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const result = await prisma.notificationTarget.deleteMany({
where: { id, userId: auth.user.id },
});
if (result.count === 0) return jsonError("Ziel nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { deliver } from "@/lib/notify/channels";
import { testMessage } from "@/lib/notify";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Schickt eine Testnachricht an genau dieses Ziel. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Loest einen ausgehenden Aufruf aus – entsprechend begrenzt.
const limited = rateLimit(
`notify-test:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const target = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!target) return jsonError("Ziel nicht gefunden", 404);
const result = await deliver(target, testMessage());
await prisma.notificationTarget.update({
where: { id },
data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message },
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { targetSafeSelect } from "@/lib/notify/types";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const targets = await prisma.notificationTarget.findMany({
where: { userId: auth.user.id },
select: targetSafeSelect,
orderBy: { name: "asc" },
});
return NextResponse.json({ targets });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { name, channel, target, secret, events, isActive } = parsed.data;
// Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung
// wie bei den Git-Verbindungen.
if (channel !== "EMAIL") {
try {
await assertAllowedUrl(target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
const duplicate = await prisma.notificationTarget.findFirst({
where: { userId: auth.user.id, name },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
const created = await prisma.notificationTarget.create({
data: {
userId: auth.user.id,
name,
channel,
target,
secretCipher: secret ? encryptSecret(secret) : null,
secretHint: secret ? tokenHint(secret) : null,
events,
isActive,
},
select: targetSafeSelect,
});
return NextResponse.json({ target: created }, { status: 201 });
}
@@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: Params) {
where: { id, ownerId: auth.user.id }, where: { id, ownerId: auth.user.id },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true, snapshot: true,
}, },
+11 -2
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git"; import { parseRepoUrl } from "@/lib/git";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation"; import { formatZodError, projectUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -75,6 +76,7 @@ export async function PATCH(request: Request, { params }: Params) {
"tags", "tags",
"favorite", "favorite",
"autoSync", "autoSync",
"progressFromTasks",
"connectionId", "connectionId",
] as const) { ] as const) {
if (data[key] !== undefined) update[key] = data[key]; if (data[key] !== undefined) update[key] = data[key];
@@ -94,9 +96,16 @@ export async function PATCH(request: Request, { params }: Params) {
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner; if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName; if (data.repoName !== undefined) update.repoName = data.repoName;
const project = await prisma.project.update({ await prisma.project.update({ where: { id }, data: update });
// 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 }, where: { id },
data: update,
include: projectListInclude, include: projectListInclude,
}); });
@@ -0,0 +1,93 @@
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 } 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>[] = [];
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);
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);
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,
});
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
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 } = 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,
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() } });
return NextResponse.json({ task }, { status: 201 });
}
+20 -1
View File
@@ -159,8 +159,25 @@ export async function POST(request: Request) {
repoOwner: incoming.repoOwner ?? null, repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null, repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null, connectionId: connection?.id ?? null,
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) => ({ const notes = incoming.notes.map((note) => ({
title: note.title ?? null, title: note.title ?? null,
content: note.content, content: note.content,
@@ -176,9 +193,10 @@ export async function POST(request: Request) {
// erneuter Import erzeugt keine Dubletten. // erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([ await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }), prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({ prisma.project.update({
where: { id: existing.id }, where: { id: existing.id },
data: { ...data, slug, notes: { create: notes } }, data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}), }),
]); ]);
results.push({ ...base, action: "overwrite" }); results.push({ ...base, action: "overwrite" });
@@ -190,6 +208,7 @@ export async function POST(request: Request) {
ownerId: auth.user.id, ownerId: auth.user.id,
slug: finalSlug, slug: finalSlug,
notes: { create: notes }, notes: { create: notes },
tasks: { create: tasks },
}, },
}); });
results.push({ results.push({
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Hit {
projectId: string;
noteId: string;
title: string | null;
/** Textausschnitt mit hervorgehobener Fundstelle. */
snippet: string;
}
/**
* Volltextsuche ueber Notizen.
*
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt:
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank.
*
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) {
return NextResponse.json({ query, hits: [], projectIds: [] });
}
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
// Anfuehrungszeichen und wirft bei Unsinn keinen Fehler.
const hits = await prisma.$queryRaw<Hit[]>`
SELECT
n."projectId" AS "projectId",
n."id" AS "noteId",
n."title" AS "title",
ts_headline(
'german',
n."content",
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Note" n
JOIN "Project" p ON p."id" = n."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(n."title", '') || ' ' || n."content")
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(n."title", '') || ' ' || n."content"),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({
query,
hits,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden.
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))),
});
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Eine Aufgabe gehoert dem, dem das zugehoerige Projekt gehoert. */
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: 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 ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskUpdateSchema.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.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels;
if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
if (data.status !== undefined && data.status !== existing.status) {
update.status = data.status;
// Beim Spaltenwechsel ans Ende der neuen Spalte.
update.position = await nextPosition(existing.projectId, data.status);
// doneAt nur setzen, wenn die Aufgabe wirklich nach DONE wandert.
update.doneAt = data.status === "DONE" ? new Date() : null;
}
const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ task });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ ok: true });
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { APP_NAME, APP_TAGLINE } from "@/lib/config"; import { APP_NAME, APP_TAGLINE, APP_VERSION } from "@/lib/config";
import "./globals.css"; import "./globals.css";
/** /**
@@ -52,7 +52,7 @@ export default async function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8"> <footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span> <span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v0.2.0</span> <span className="font-mono text-slate-600">v{APP_VERSION}</span>
</div> </div>
</footer> </footer>
</body> </body>
+7
View File
@@ -5,6 +5,7 @@ import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -59,6 +60,12 @@ export default async function ProjectPage({ params }: PageProps) {
connections={connections as unknown as ConnectionSafe[]} connections={connections as unknown as ConnectionSafe[]}
/> />
<TaskBoard
projectId={detail.id}
tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks}
/>
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5"> <div className="space-y-6 lg:col-span-5">
<section className="glass p-5"> <section className="glass p-5">
+90
View File
@@ -0,0 +1,90 @@
import { CircleCheck, CircleDashed, CircleSlash, CircleX, LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
const META: Record<
CiState,
{ label: string; short: string; className: string; icon: typeof CircleCheck; spin?: boolean }
> = {
success: {
label: "CI erfolgreich",
short: "CI ok",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
icon: CircleCheck,
},
failure: {
label: "CI fehlgeschlagen",
short: "CI rot",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
icon: CircleX,
},
running: {
label: "CI laeuft",
short: "CI laeuft",
className: "bg-amber-500/12 text-amber-200 ring-amber-400/30",
icon: LoaderCircle,
spin: true,
},
pending: {
label: "CI wartet",
short: "CI wartet",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
icon: CircleDashed,
},
cancelled: {
label: "CI abgebrochen",
short: "CI abgebrochen",
className: "bg-slate-500/12 text-slate-400 ring-slate-400/20",
icon: CircleSlash,
},
};
export function CiBadge({
state,
name,
url,
compact = false,
}: {
state: string | null | undefined;
name?: string | null;
url?: string | null;
compact?: boolean;
}) {
if (!state || !(state in META)) return null;
const meta = META[state as CiState];
const Icon = meta.icon;
const content = (
<>
<Icon className={cn("h-3 w-3 shrink-0", meta.spin && "animate-spin")} />
{compact ? meta.short : (name ?? meta.label)}
</>
);
const className = cn(
"inline-flex items-center gap-1.5 rounded-full ring-1 ring-inset",
meta.className,
compact ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-[11px]",
);
if (url && !compact) {
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className={cn(className, "transition-all hover:brightness-125")}
title={meta.label}
>
{content}
</a>
);
}
return (
<span className={className} title={name ? `${meta.label} · ${name}` : meta.label}>
{content}
</span>
);
}
+109 -9
View File
@@ -3,7 +3,9 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Activity,
AlertTriangle, AlertTriangle,
CircleDot,
ExternalLink, ExternalLink,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
@@ -18,6 +20,8 @@ import {
Star, Star,
Tag, Tag,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { asArray, asRecord } from "@/types"; import { asArray, asRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
@@ -25,10 +29,13 @@ import type {
ProjectDetail, ProjectDetail,
ReleaseInfo, ReleaseInfo,
TagInfo, TagInfo,
TicketInfo,
} from "@/types"; } from "@/types";
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils"; import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
const TABS = [ const TABS = [
{ key: "pulls", label: "PRs", icon: GitPullRequest },
{ key: "issues", label: "Issues", icon: CircleDot },
{ key: "branches", label: "Branches", icon: GitBranch }, { key: "branches", label: "Branches", icon: GitBranch },
{ key: "tags", label: "Tags", icon: Tag }, { key: "tags", label: "Tags", icon: Tag },
{ key: "commits", label: "Commits", icon: GitCommitHorizontal }, { key: "commits", label: "Commits", icon: GitCommitHorizontal },
@@ -39,7 +46,7 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) { export function GitPanel({ project }: { project: ProjectDetail }) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>("branches"); const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
@@ -49,6 +56,9 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const commits = asArray<CommitInfo>(snapshot?.commits); const commits = asArray<CommitInfo>(snapshot?.commits);
const releases = asArray<ReleaseInfo>(snapshot?.releases); const releases = asArray<ReleaseInfo>(snapshot?.releases);
const languages = asRecord(snapshot?.languages); const languages = asRecord(snapshot?.languages);
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl); const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
@@ -118,6 +128,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
) : null} ) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div> </div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning> <p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt {project.lastSyncedAt
@@ -224,6 +235,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div> </div>
</div> </div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="glass p-4">
<div className="mb-2.5 flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Activity className="h-3 w-3" />
{/* Der Zeitraum richtet sich danach, wie weit die geholten
Commits zurueckreichen – hoechstens 30 Tage. */}
Commits der letzten {activity.length} Tage
</p>
<span className="text-xs tabular-nums text-slate-400">
{activity.reduce((sum, value) => sum + value, 0)}
</span>
</div>
<Sparkline values={activity} height={40} />
</div>
) : null}
{/* Sprachen */} {/* Sprachen */}
{languageTotal > 0 ? ( {languageTotal > 0 ? (
<div className="glass p-4"> <div className="glass p-4">
@@ -263,14 +292,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<div className="glass overflow-hidden"> <div className="glass overflow-hidden">
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2"> <div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
{TABS.map(({ key, label, icon: Icon }) => { {TABS.map(({ key, label, icon: Icon }) => {
const count = const count = {
key === "branches" pulls: pulls.length,
? branches.length issues: issues.length,
: key === "tags" branches: branches.length,
? tags.length tags: tags.length,
: key === "commits" commits: commits.length,
? commits.length releases: releases.length,
: releases.length; }[key];
return ( return (
<button <button
key={key} key={key}
@@ -292,6 +321,16 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div> </div>
<div className="max-h-[26rem] overflow-y-auto"> <div className="max-h-[26rem] overflow-y-auto">
{tab === "pulls" ? (
<TicketList
tickets={pulls}
empty="Keine offenen Pull oder Merge Requests."
icon={GitPullRequest}
/>
) : null}
{tab === "issues" ? (
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null} {tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null} {tab === "tags" ? <TagList tags={tags} /> : null}
{tab === "commits" ? <CommitList commits={commits} /> : null} {tab === "commits" ? <CommitList commits={commits} /> : null}
@@ -342,6 +381,67 @@ function Empty({ text }: { text: string }) {
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>; return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
} }
function TicketList({
tickets,
empty,
icon: Icon,
}: {
tickets: TicketInfo[];
empty: string;
icon: typeof GitPullRequest;
}) {
if (tickets.length === 0) return <Empty text={empty} />;
return (
<div>
{tickets.map((ticket) => (
<Row key={`${ticket.number}-${ticket.title}`}>
<div className="flex min-w-0 items-start gap-2">
<Icon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
ticket.draft ? "text-slate-600" : "text-emerald-400/70",
)}
/>
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">
{ticket.title}
{ticket.draft ? (
<span className="ml-1.5 rounded bg-slate-500/15 px-1 py-0.5 text-[10px] text-slate-400">
Entwurf
</span>
) : null}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
<span className="font-mono text-slate-600">#{ticket.number}</span>
<span>{ticket.author ?? "unbekannt"}</span>
{ticket.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
{label}
</span>
))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
{ticket.url ? (
<a
href={ticket.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Im Git-System oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
function BranchList({ branches }: { branches: BranchInfo[] }) { function BranchList({ branches }: { branches: BranchInfo[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />; if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return ( return (
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
closestCorners,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
/**
* Statusspalten mit Ziehen und Ablegen.
*
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert;
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden.
*/
export function KanbanBoard({
projects,
showArchived,
}: {
projects: ProjectListItem[];
showArchived: boolean;
}) {
const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null);
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine
// oertliche Ausnahme mehr.
useEffect(() => {
setOverrides((current) => {
const next: Record<string, ProjectStatus> = {};
for (const [id, status] of Object.entries(current)) {
const project = projects.find((entry) => entry.id === id);
if (project && project.status !== status) next[id] = status;
}
return Object.keys(next).length === Object.keys(current).length ? current : next;
});
}, [projects]);
const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
// nicht mehr anklicken.
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status;
const columns = useMemo(() => {
const visible = STATUS_ORDER.filter(
(status) => showArchived || status !== "ARCHIVED",
);
return visible.map((status) => ({
status,
items: projects.filter((project) => statusOf(project) === status),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, overrides, showArchived]);
const active = activeId ? projects.find((project) => project.id === activeId) : null;
function onDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
async function onDragEnd(event: DragEndEvent) {
setActiveId(null);
const id = String(event.active.id);
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null;
if (!target) return;
const project = projects.find((entry) => entry.id === id);
if (!project) return;
const previous = statusOf(project);
if (previous === target) return;
setOverrides((current) => ({ ...current, [id]: target }));
try {
const response = await fetch(`/api/projects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
setOverrides((current) => {
const next = { ...current };
delete next[id];
return next;
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)}
>
<div className="flex gap-3 overflow-x-auto pb-3">
{columns.map((column) => (
<Column key={column.status} status={column.status} count={column.items.length}>
{column.items.map((project) => (
<KanbanCard key={project.id} project={project} />
))}
</Column>
))}
</div>
{/* Die mitgezogene Karte haengt am Cursor, nicht in der Spalte. */}
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <KanbanCard project={active} overlay /> : null}
</DragOverlay>
</DndContext>
);
}
function Column({
status,
count,
children,
}: {
status: ProjectStatus;
count: number;
children: React.ReactNode;
}) {
const meta = STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<section
ref={setNodeRef}
// Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status}
className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<header className="flex items-center gap-2 px-3 py-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h3>
<span className="ml-auto rounded-full bg-white/6 px-1.5 py-0.5 text-[10px] tabular-nums text-slate-400">
{count}
</span>
</header>
<div className="flex min-h-[7rem] flex-1 flex-col gap-2 px-2 pb-2">
{children}
{count === 0 ? (
<p
className={cn(
"rounded-xl border border-dashed border-white/8 px-3 py-6 text-center text-[11px] text-slate-600 transition-colors",
isOver && "border-violet-400/40 text-violet-200",
)}
>
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</section>
);
}
function KanbanCard({
project,
overlay = false,
}: {
project: ProjectListItem;
overlay?: boolean;
}) {
const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id });
return (
<article
ref={overlay ? undefined : setNodeRef}
className={cn(
"popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
<div className="flex items-start gap-2">
{/* Nur der Griff zieht – so bleibt der Titel ein normaler Link. */}
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-600 transition-colors hover:text-slate-300 active:cursor-grabbing"
aria-label={`${project.name} verschieben`}
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1">
<Link
href={`/projects/${project.id}`}
className="block truncate text-sm font-medium text-slate-100 hover:text-violet-200"
>
{project.name}
</Link>
{project.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-slate-500">
{truncate(project.summary, 90)}
</p>
) : null}
</div>
</div>
<div className="mt-2.5 h-1 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
<CiBadge state={project.snapshot?.ciState} compact />
{project.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" />
{project.snapshot.defaultBranch}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
<span className="ml-auto tabular-nums">{project.progress}%</span>
</div>
</article>
);
}
+132 -4
View File
@@ -1,11 +1,14 @@
"use client"; "use client";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { import {
Columns3,
Database, Database,
Download, Download,
LayoutGrid, LayoutGrid,
ListFilter, ListFilter,
NotebookPen,
Plus, Plus,
Rows3, Rows3,
Search, Search,
@@ -14,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { ImportDialog } from "@/components/ImportDialog"; import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
@@ -21,8 +25,34 @@ import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types"; import type { ConnectionSafe, ProjectListItem } from "@/types";
interface NoteHit {
projectId: string;
noteId: string;
title: string | null;
/** Ausschnitt mit [[…]] um die Fundstelle – siehe /api/search. */
snippet: string;
}
/**
* Wandelt die [[…]]-Markierungen von ts_headline in hervorgehobene Stellen.
* Bewusst kein HTML aus der Datenbank einbetten – der Text stammt aus einer
* Notiz und damit vom Benutzer.
*/
function highlight(snippet: string): React.ReactNode[] {
return snippet.split(/(\[\[.*?\]\])/g).map((part, index) => {
if (part.startsWith("[[") && part.endsWith("]]")) {
return (
<mark key={index} className="rounded bg-violet-500/25 px-0.5 text-violet-100">
{part.slice(2, -2)}
</mark>
);
}
return <span key={index}>{part}</span>;
});
}
type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped"; type ViewMode = "grid" | "grouped" | "kanban";
const SORT_LABEL: Record<SortKey, string> = { const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert", updated: "Zuletzt geaendert",
@@ -48,6 +78,45 @@ export function ProjectBoard({
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null); const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
/**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
* unnoetig aufblaehen. Stattdessen sucht Postgres serverseitig, sobald der
* Begriff lang genug ist. Entprellt, damit nicht jeder Tastendruck eine
* Abfrage ausloest.
*/
useEffect(() => {
const needle = query.trim();
if (needle.length < 2) {
setNoteHits([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(needle)}`, {
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
setNoteHits(body.hits ?? []);
} catch {
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
const noteHitProjectIds = useMemo(
() => new Set(noteHits.map((hit) => hit.projectId)),
[noteHits],
);
const counts = useMemo(() => { const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>(); const map = new Map<ProjectStatus, number>();
@@ -71,7 +140,9 @@ export function ProjectBoard({
(project.summary ?? "").toLowerCase().includes(needle) || (project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) || project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle) (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) ||
// Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id)
); );
}); });
@@ -92,7 +163,7 @@ export function ProjectBoard({
}); });
return sorted; return sorted;
}, [projects, query, statusFilter, sort, showArchived]); }, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]);
const grouped = useMemo(() => { const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({ return STATUS_ORDER.map((status) => ({
@@ -178,6 +249,18 @@ export function ProjectBoard({
> >
<Rows3 className="h-4 w-4" /> <Rows3 className="h-4 w-4" />
</button> </button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</button>
</div> </div>
<button <button
@@ -256,9 +339,54 @@ export function ProjectBoard({
</div> </div>
</div> </div>
{/* Treffer in Notizen */}
{noteHits.length > 0 ? (
<div className="glass fade-up p-4">
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<NotebookPen className="h-3.5 w-3.5 text-violet-300" />
Treffer in Notizen
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
{noteHits.length}
</span>
</h2>
<ul className="space-y-1">
{noteHits.slice(0, 8).map((hit) => {
const project = projects.find((entry) => entry.id === hit.projectId);
return (
<li key={hit.noteId}>
<Link
href={`/projects/${hit.projectId}`}
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
>
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm text-slate-200">
{project?.name ?? "Projekt"}
</span>
{hit.title ? (
<span className="text-[11px] text-slate-500">· {hit.title}</span>
) : null}
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
{highlight(hit.snippet)}
</span>
</Link>
</li>
);
})}
</ul>
{noteHits.length > 8 ? (
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
… und {noteHits.length - 8} weitere
</p>
) : null}
</div>
) : null}
{/* Inhalt */} {/* Inhalt */}
{visible.length === 0 ? ( {visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} /> <EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} />
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => ( {visible.map((project, index) => (
+24 -1
View File
@@ -4,16 +4,19 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { import {
AlertTriangle, AlertTriangle,
CheckSquare,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
NotebookPen, NotebookPen,
Star, Star,
Tag, Tag,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf } from "@/lib/status"; import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils"; import { cn, formatRelative, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types"; import { asArray, type ProjectListItem } from "@/types";
const PROVIDER_LABEL: Record<string, string> = { const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub", GITHUB: "GitHub",
@@ -29,6 +32,7 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot; const snapshot = project.snapshot;
const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() { async function toggleFavorite() {
@@ -110,8 +114,17 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</div> </div>
</div> </div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="mt-3">
<Sparkline values={activity} height={20} />
</div>
) : null}
{/* Git-Kurzinfo */} {/* Git-Kurzinfo */}
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400"> <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
{project.connection ? ( {project.connection ? (
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1"> <span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
<span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" /> <span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
@@ -143,6 +156,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span> </span>
) : null} ) : null}
{project._count.tasks > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={`${project.tasks.length} von ${project._count.tasks} Aufgaben erledigt`}
>
<CheckSquare className="h-3.5 w-3.5 text-slate-500" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? ( {project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" /> <NotebookPen className="h-3.5 w-3.5 text-slate-500" />
+21 -1
View File
@@ -32,6 +32,7 @@ interface FormState {
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
autoSync: boolean; autoSync: boolean;
progressFromTasks: boolean;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -49,6 +50,7 @@ const EMPTY: FormState = {
repoOwner: "", repoOwner: "",
repoName: "", repoName: "",
autoSync: true, autoSync: true,
progressFromTasks: false,
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -90,6 +92,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: project.repoOwner ?? "", repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "", repoName: project.repoName ?? "",
autoSync: project.autoSync, autoSync: project.autoSync,
progressFromTasks: project.progressFromTasks,
} }
: EMPTY, : EMPTY,
); );
@@ -158,6 +161,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: form.repoOwner, repoOwner: form.repoOwner,
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync, autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
}; };
try { try {
@@ -291,9 +295,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5} step={5}
value={form.progress} value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))} onChange={(event) => update("progress", Number(event.target.value))}
className="range" disabled={form.progressFromTasks}
className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")}
style={{ "--fill": `${form.progress}%` } as React.CSSProperties} style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
/> />
<label className="mt-2 flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.progressFromTasks}
onChange={(event) => update("progressFromTasks", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aus Aufgaben berechnen
<span className="mt-0.5 block text-[11px] text-slate-600">
Der Fortschritt ergibt sich dann aus dem Anteil erledigter Aufgaben und
laesst sich nicht mehr von Hand schieben.
</span>
</span>
</label>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
+15 -2
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
CalendarDays, CalendarDays,
CheckSquare,
Download, Download,
ExternalLink, ExternalLink,
FileJson, FileJson,
@@ -207,7 +208,15 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */} {/* Fortschritt direkt regelbar */}
<div className="mt-6"> <div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500"> <div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span>Fortschritt</span> <span className="flex items-center gap-1.5">
Fortschritt
{project.progressFromTasks ? (
<span className="inline-flex items-center gap-1 rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
<CheckSquare className="h-2.5 w-2.5" />
aus Aufgaben
</span>
) : null}
</span>
<span className="tabular-nums text-slate-300">{progress}%</span> <span className="tabular-nums text-slate-300">{progress}%</span>
</div> </div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem {/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
@@ -223,7 +232,11 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })} onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })} onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen" aria-label="Fortschritt anpassen"
className="range" // Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier
// wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder
// ueberschrieben.
disabled={project.progressFromTasks}
className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")}
style={{ "--fill": `${progress}%` } as React.CSSProperties} style={{ "--fill": `${progress}%` } as React.CSSProperties}
/> />
</div> </div>
+57
View File
@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
/**
* Commit-Aktivitaet als kleine Balkenreihe.
*
* Bewusst als reines SVG ohne Diagrammbibliothek: es sind dreissig Werte,
* die Achsen, Legende und Tooltips einer Bibliothek waeren hier nur Ballast.
*/
export function Sparkline({
values,
className,
height = 28,
label = "Commits",
}: {
values: number[];
className?: string;
height?: number;
label?: string;
}) {
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
if (total === 0) return null;
const max = Math.max(...values);
const gap = 1;
const barWidth = 3;
const width = values.length * (barWidth + gap) - gap;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={cn("w-full", className)}
style={{ height }}
role="img"
aria-label={`${label} der letzten ${values.length} Tage: insgesamt ${total}`}
>
<title>{`${label} der letzten ${values.length} Tage: insgesamt ${total}`}</title>
{values.map((value, index) => {
// Auch ein einzelner Commit soll sichtbar sein, daher die Untergrenze.
const barHeight = value === 0 ? 1.5 : Math.max(3, (value / max) * height);
return (
<rect
key={index}
x={index * (barWidth + gap)}
y={height - barHeight}
width={barWidth}
height={barHeight}
rx={1}
className={value === 0 ? "fill-white/10" : "fill-violet-400/80"}
/>
);
})}
</svg>
);
}
+3
View File
@@ -108,6 +108,9 @@ function UserMenu({ user }: { user: SessionUser }) {
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
// Auf schmalen Anzeigen bleibt nur das Kuerzel sichtbar – ohne
// Beschriftung liest ein Screenreader dann nur zwei Buchstaben vor.
aria-label={`Benutzermenue fuer ${label}`}
> >
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white"> <span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
{initials} {initials}
@@ -0,0 +1,515 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Bell,
BellRing,
CheckCircle2,
KeyRound,
Loader2,
Mail,
Pencil,
Plus,
Trash2,
Webhook,
XCircle,
} from "lucide-react";
import type { NotificationChannel, NotificationEvent } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { EVENT_META, NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { cn, formatRelative } from "@/lib/utils";
export interface NotificationTargetEntry {
id: string;
name: string;
channel: NotificationChannel;
target: string;
secretHint: string | null;
events: NotificationEvent[];
isActive: boolean;
lastSentAt: string | Date | null;
lastOk: boolean | null;
lastMessage: string | null;
}
const CHANNEL_META: Record<
NotificationChannel,
{ label: string; icon: typeof Webhook; hint: string; placeholder: string }
> = {
NTFY: {
label: "ntfy",
icon: BellRing,
hint: "Vollstaendige Themen-URL, z. B. https://ntfy.sh/mein-geheimes-thema",
placeholder: "https://ntfy.sh/mein-thema",
},
WEBHOOK: {
label: "Webhook",
icon: Webhook,
hint: "Bekommt JSON per POST. Mit Geheimnis wird der Rumpf signiert (X-Nebula-Signature).",
placeholder: "https://mein-dienst.de/haken",
},
EMAIL: {
label: "E-Mail",
icon: Mail,
hint: "Braucht SMTP-Zugangsdaten in der .env der Instanz.",
placeholder: "ich@example.com",
},
};
export function NotificationSection({
targets,
emailAvailable,
}: {
targets: NotificationTargetEntry[];
emailAvailable: boolean;
}) {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTargetEntry | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null);
async function test(entry: NotificationTargetEntry) {
setBusyId(entry.id);
setNotice(null);
try {
const response = await fetch(`/api/notifications/${entry.id}/test`, { method: "POST" });
const body = await response.json().catch(() => ({}));
setNotice({ ok: response.ok, text: body.message ?? body.error ?? "Unbekannte Antwort." });
router.refresh();
} finally {
setBusyId(null);
}
}
async function remove(entry: NotificationTargetEntry) {
if (!window.confirm(`Ziel „${entry.name}“ entfernen?`)) return;
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setBusyId(null);
router.refresh();
}
async function toggle(entry: NotificationTargetEntry) {
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !entry.isActive }),
}).catch(() => undefined);
setBusyId(null);
router.refresh();
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Bell className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Benachrichtigungen
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Meldung bei fehlgeschlagenem Abgleich, roter CI oder neuem Release. Gemeldet
wird nur bei einer <strong className="text-slate-300">Aenderung</strong> –
nicht bei jedem Durchlauf.
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
Ziel hinzufuegen
</button>
</div>
{targets.length === 0 ? (
<p className="rounded-xl border border-dashed border-white/8 px-4 py-6 text-center text-sm text-slate-500">
Noch kein Ziel eingerichtet.
</p>
) : (
<div className="space-y-2">
{targets.map((entry) => {
const meta = CHANNEL_META[entry.channel];
const Icon = meta.icon;
return (
<div key={entry.id} className="glass-soft px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
<Icon className="h-3.5 w-3.5 shrink-0 text-violet-300" />
{entry.name}
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
{meta.label}
</span>
{!entry.isActive ? (
<span className="rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-400">
pausiert
</span>
) : null}
{entry.secretHint ? (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-amber-300/70">
<KeyRound className="h-2.5 w-2.5" />
{entry.secretHint}
</span>
) : null}
</p>
<p className="mt-0.5 truncate font-mono text-[11px] text-slate-500">
{entry.target}
</p>
<p className="mt-1.5 flex flex-wrap gap-1">
{entry.events.map((event) => (
<span
key={event}
className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400"
>
{EVENT_META[event].emoji} {EVENT_META[event].label}
</span>
))}
</p>
{entry.lastSentAt ? (
<p
className={cn(
"mt-1.5 flex items-center gap-1.5 text-[11px]",
entry.lastOk ? "text-emerald-200/80" : "text-rose-200/80",
)}
suppressHydrationWarning
>
{entry.lastOk ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{formatRelative(entry.lastSentAt)} · {entry.lastMessage}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{busyId === entry.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => test(entry)}
>
Testen
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => toggle(entry)}
title={entry.isActive ? "Pausieren" : "Fortsetzen"}
>
{entry.isActive ? "Pause" : "An"}
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
onClick={() => {
setEditing(entry);
setDialogOpen(true);
}}
aria-label="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
onClick={() => remove(entry)}
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
{!emailAvailable ? (
<p className="mt-3 text-[11px] text-slate-600">
E-Mail steht nicht bereit: In der <code className="text-violet-300">.env</code> der
Instanz fehlt <code className="text-violet-300">SMTP_HOST</code>. ntfy und Webhook
funktionieren ohne weitere Einrichtung.
</p>
) : null}
<TargetDialog
open={dialogOpen}
entry={editing}
emailAvailable={emailAvailable}
onClose={() => {
setDialogOpen(false);
setEditing(null);
}}
/>
</section>
);
}
function TargetDialog({
open,
entry,
emailAvailable,
onClose,
}: {
open: boolean;
entry: NotificationTargetEntry | null;
emailAvailable: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [channel, setChannel] = useState<NotificationChannel>("NTFY");
const [target, setTarget] = useState("");
const [secret, setSecret] = useState("");
const [events, setEvents] = useState<NotificationEvent[]>(["SYNC_FAILED", "CI_FAILED"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(entry);
useEffect(() => {
if (!open) return;
setName(entry?.name ?? "");
setChannel(entry?.channel ?? "NTFY");
setTarget(entry?.target ?? "");
setSecret("");
setEvents(entry?.events ?? ["SYNC_FAILED", "CI_FAILED"]);
setError(null);
setFieldErrors({});
}, [open, entry]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldErrors({});
const body: Record<string, unknown> = { name, target, events };
if (!isEdit) body.channel = channel;
if (!isEdit || secret.trim()) body.secret = secret;
try {
const response = await fetch(
isEdit ? `/api/notifications/${entry?.id}` : "/api/notifications",
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
}
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
const meta = CHANNEL_META[channel];
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Ziel bearbeiten" : "Neues Benachrichtigungsziel"}
description={
isEdit ? "Der Zustellweg selbst laesst sich nicht mehr aendern." : undefined
}
>
<form onSubmit={submit} className="space-y-4">
{!isEdit ? (
<div>
<span className="field-label">Weg</span>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(CHANNEL_META) as NotificationChannel[]).map((value) => {
const option = CHANNEL_META[value];
const Icon = option.icon;
const disabled = value === "EMAIL" && !emailAvailable;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => setChannel(value)}
title={disabled ? "Kein SMTP in der .env hinterlegt" : undefined}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
channel === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<Icon className="h-3.5 w-3.5" />
{option.label}
</span>
</button>
);
})}
</div>
</div>
) : null}
<div>
<label className="field-label" htmlFor="notify-name">
Name *
</label>
<input
id="notify-name"
className="field"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Handy"
required
maxLength={60}
/>
{fieldErrors.name ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.name}</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="notify-target">
{channel === "EMAIL" ? "E-Mail-Adresse *" : "URL *"}
</label>
<input
id="notify-target"
className="field font-mono text-xs"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={meta.placeholder}
required
/>
<p className="mt-1 text-[11px] text-slate-500">{meta.hint}</p>
{fieldErrors.target ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.target}</p>
) : null}
</div>
{channel !== "EMAIL" ? (
<div>
<label className="field-label" htmlFor="notify-secret">
Geheimnis {isEdit ? "(leer = unveraendert)" : "(optional)"}
</label>
<input
id="notify-secret"
className="field font-mono text-xs"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
placeholder={isEdit ? "••••••••" : ""}
/>
<p className="mt-1 text-[11px] text-slate-500">
{channel === "NTFY"
? "Wird als Bearer-Token gesendet – fuer geschuetzte Themen."
: "Signiert den Rumpf per HMAC-SHA256 im Kopf X-Nebula-Signature."}
</p>
</div>
) : null}
<div>
<span className="field-label">Anlaesse *</span>
<div className="space-y-1.5">
{NOTIFICATION_EVENTS.map((event) => {
const option = EVENT_META[event];
const checked = events.includes(event);
return (
<label
key={event}
className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-white/8 bg-black/20 px-3 py-2"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setEvents((current) =>
e.target.checked
? [...current, event]
: current.filter((value) => value !== event),
)
}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">
{option.emoji} {option.label}
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
{option.description}
</span>
</span>
</label>
);
})}
</div>
{fieldErrors.events ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.events}</p>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || events.length === 0}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
);
}
+126
View File
@@ -9,6 +9,7 @@ import {
Download, Download,
Fingerprint, Fingerprint,
FolderGit2, FolderGit2,
Hourglass,
Loader2, Loader2,
Lock, Lock,
Plus, Plus,
@@ -67,6 +68,12 @@ export interface AdminData {
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */ /** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
eligible: number; eligible: number;
}; };
stale: {
afterDays: number;
lastCheckAt: string | Date | null;
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
listeners: number;
};
} }
type Notice = { ok: boolean; text: string } | null; type Notice = { ok: boolean; text: string } | null;
@@ -187,6 +194,7 @@ export function AdminManager({ data }: { data: AdminData }) {
</section> </section>
<AutoSyncSection data={data.autoSync} /> <AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} />
{/* ── Instanz ─────────────────────────────────────── */} {/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5"> <section className="glass p-5">
@@ -574,6 +582,124 @@ function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
); );
} }
const STALE_OPTIONS = [14, 30, 60, 90, 180, 365];
function StaleSection({ data }: { data: AdminData["stale"] }) {
const router = useRouter();
const [days, setDays] = useState(data.afterDays);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(value: number) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staleAfterDays: value }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setNotice({ ok: true, text: "Gespeichert." });
router.refresh();
} catch (caught) {
setDays(data.afterDays);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-amber-500/12 text-amber-300">
<Hourglass className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Stillstand
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Ab wann ein Projekt als eingeschlafen gilt. Massstab ist der letzte Commit im
verknuepften Repository – das Aenderungsdatum taugt nicht, weil jeder Abgleich
es anfasst. Projekte ohne Repository bleiben aussen vor, ebenso fertige und
archivierte.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="stale-days">
Schwelle
</label>
<select
id="stale-days"
className="field"
value={days}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setDays(value);
void save(value);
}}
>
{STALE_OPTIONS.map((value) => (
<option key={value} value={value}>
nach {value} Tagen ohne Commit
</option>
))}
</select>
</div>
<dl className="grid grid-cols-2 gap-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Letzte Pruefung
</dt>
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
{data.lastCheckAt ? formatRelative(data.lastCheckAt) : "noch keine"}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Empfaenger
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.listeners}</dd>
</div>
</dl>
</div>
{data.listeners === 0 ? (
<p className="mt-3 text-[11px] text-slate-600">
Niemand hoert derzeit auf diesen Anlass – die Pruefung laeuft dann gar nicht
erst. Einzurichten unter „Mein Konto → Benachrichtigungen“.
</p>
) : (
<p className="mt-3 text-[11px] text-slate-600">
Die Pruefung laeuft hoechstens einmal am Tag. Je Projekt wird einmal gemeldet;
erneut erst, wenn danach wieder ein Commit kam und es wieder still wurde.
</p>
)}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
function ModeCard({ function ModeCard({
active, active,
disabled, disabled,
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, Loader2, Plus } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { TaskCard } from "@/components/tasks/TaskCard";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn } from "@/lib/utils";
type Columns = Record<TaskStatus, Task[]>;
function group(tasks: Task[]): Columns {
const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns;
for (const task of [...tasks].sort((a, b) => a.position - b.position)) {
columns[task.status].push(task);
}
return columns;
}
/**
* Aufgabenbrett eines Projekts.
*
* Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte
* hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen.
* Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue
* Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist
* wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus.
*/
export function TaskBoard({
projectId,
tasks,
progressFromTasks,
}: {
projectId: string;
tasks: Task[];
progressFromTasks: boolean;
}) {
const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks));
const [activeId, setActiveId] = useState<string | null>(null);
const [editing, setEditing] = useState<Task | null>(null);
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null);
const [saving, setSaving] = useState(false);
const dragging = useRef(false);
// Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst
// wuerde die Karte mitten in der Bewegung zurueckspringen.
useEffect(() => {
if (!dragging.current) setColumns(group(tasks));
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const allTasks = useMemo(() => Object.values(columns).flat(), [columns]);
const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null;
const total = allTasks.length;
const done = columns.DONE.length;
function containerOf(id: string): TaskStatus | null {
if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus;
for (const status of TASK_STATUS_ORDER) {
if (columns[status].some((task) => task.id === id)) return status;
}
return null;
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
/** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((task) => task.id === activeKey);
if (!moved) return current;
const targetIndex = current[to].findIndex((task) => task.id === overKey);
const insertAt = targetIndex >= 0 ? targetIndex : current[to].length;
return {
...current,
[from]: current[from].filter((task) => task.id !== activeKey),
[to]: [
...current[to].slice(0, insertAt),
{ ...moved, status: to },
...current[to].slice(insertAt),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const overContainer = containerOf(overKey);
if (container && container === overContainer) {
const list = columns[container];
const oldIndex = list.findIndex((task) => task.id === activeKey);
const newIndex = list.findIndex((task) => task.id === overKey);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const reordered = [...list];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
next = { ...columns, [container]: reordered };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
async function persist(next: Columns) {
setSaving(true);
try {
const payload = {
columns: Object.fromEntries(
TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]),
),
};
const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(tasks));
} finally {
setSaving(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<CheckSquare className="h-4 w-4" />
</span>
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgaben
{total > 0 ? (
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{done}/{total}
</span>
) : null}
{saving ? <Loader2 className="h-3 w-3 animate-spin text-slate-500" /> : null}
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{progressFromTasks
? "Der Fortschritt des Projekts folgt diesen Aufgaben."
: "Ziehen zum Verschieben und Sortieren."}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setCreatingIn("TODO")}
>
<Plus className="h-3.5 w-3.5" />
Aufgabe
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(tasks));
}}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{TASK_STATUS_ORDER.map((status) => (
<TaskColumn
key={status}
status={status}
tasks={columns[status]}
onAdd={() => setCreatingIn(status)}
onEdit={setEditing}
/>
))}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <TaskCard task={active} overlay /> : null}
</DragOverlay>
</DndContext>
<TaskDialog
open={creatingIn !== null || editing !== null}
projectId={projectId}
task={editing}
initialStatus={creatingIn ?? "TODO"}
onClose={() => {
setCreatingIn(null);
setEditing(null);
}}
/>
</section>
);
}
function TaskColumn({
status,
tasks,
onAdd,
onEdit,
}: {
status: TaskStatus;
tasks: Task[];
onAdd: () => void;
onEdit: (task: Task) => void;
}) {
const meta = TASK_STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div
ref={setNodeRef}
data-task-column={status}
className={cn(
"flex min-h-[8rem] flex-col rounded-xl border border-white/6 bg-black/20 p-2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<div className="mb-2 flex items-center gap-2 px-1">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{meta.label}
</h3>
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
<SortableContext
items={tasks.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
<p className="rounded-lg border border-dashed border-white/8 px-2 py-4 text-center text-[10px] text-slate-600">
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</SortableContext>
<button
type="button"
onClick={onAdd}
className="mt-1.5 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-white/5 hover:text-slate-300"
>
<Plus className="h-3 w-3" />
hinzufuegen
</button>
</div>
);
}
function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(isDragging && "opacity-30")}
>
<TaskCard task={task} onEdit={onEdit} dragHandle={{ ...attributes, ...listeners }} />
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import type { Task } from "@prisma/client";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
function dueTone(due: Date | string | null, done: boolean) {
if (!due || done) return null;
const date = new Date(due);
if (Number.isNaN(date.getTime())) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const days = Math.round((target.getTime() - today.getTime()) / 86_400_000);
if (days < 0) return { className: "text-rose-300", label: "ueberfaellig" };
if (days === 0) return { className: "text-amber-300", label: "heute" };
if (days <= 3) return { className: "text-amber-200/70", label: null };
return { className: "text-slate-500", label: null };
}
export function TaskCard({
task,
onEdit,
dragHandle,
overlay = false,
}: {
task: Task;
onEdit?: (task: Task) => void;
dragHandle?: Record<string, unknown>;
overlay?: boolean;
}) {
const done = task.status === "DONE";
const due = dueTone(task.dueDate, done);
return (
<article
className={cn(
"group rounded-lg border border-white/8 bg-[#0b0d1c] p-2 transition-colors",
!overlay && "hover:border-white/16",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
>
<div className="flex items-start gap-1.5">
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-700 transition-colors hover:text-slate-400 active:cursor-grabbing"
aria-label={`${task.title} verschieben`}
{...dragHandle}
>
<GripVertical className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => onEdit?.(task)}
disabled={!onEdit}
className="min-w-0 flex-1 text-left"
>
<p
className={cn(
"break-words text-xs leading-snug text-slate-200",
done && "text-slate-500 line-through",
)}
>
{task.title}
</p>
{(task.description || task.dueDate || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null}
{task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
{due?.label ?? formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-slate-400">
{label}
</span>
))}
</div>
)}
</button>
</div>
</article>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
function toDateInput(value: Date | string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
export function TaskDialog({
open,
projectId,
task,
initialStatus,
onClose,
}: {
open: boolean;
projectId: string;
task: Task | null;
initialStatus: TaskStatus;
onClose: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(task);
useEffect(() => {
if (!open) return;
setTitle(task?.title ?? "");
setDescription(task?.description ?? "");
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setError(null);
}, [open, task, initialStatus]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
const body = {
title,
description,
status,
// Ohne Uhrzeit auf Mittag legen, damit Zeitzonenverschiebungen das
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
};
try {
const response = await fetch(
isEdit ? `/api/tasks/${task?.id}` : `/api/projects/${projectId}/tasks`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
async function remove() {
if (!task) return;
if (!window.confirm(`Aufgabe „${task.title}“ loeschen?`)) return;
setBusy(true);
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
if (!response.ok) throw new Error("Loeschen fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
description={isEdit ? undefined : "Titel genuegt – alles andere ist optional."}
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="task-title">
Titel *
</label>
<input
id="task-title"
className="field"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={200}
autoFocus
/>
</div>
<div>
<span className="field-label">Spalte</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASK_STATUS_ORDER.map((value) => {
const meta = TASK_STATUS_META[value];
return (
<button
key={value}
type="button"
onClick={() => setStatus(value)}
className={cn(
"rounded-lg border px-2 py-2 text-xs transition-all",
status === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
/>
{meta.label}
</span>
</button>
);
})}
</div>
</div>
<div>
<label className="field-label" htmlFor="task-description">
Beschreibung
</label>
<textarea
id="task-description"
className="field min-h-24 resize-y font-mono text-[13px]"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Markdown wird dargestellt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-due">
Faellig am
</label>
<input
id="task-due"
className="field"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="task-labels">
Labels (kommagetrennt)
</label>
<input
id="task-labels"
className="field"
value={labels}
onChange={(event) => setLabels(event.target.value)}
placeholder="bug, ui"
/>
</div>
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
{isEdit ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Loeschen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy || !title.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("ein-langes-testpasswort", hash)).resolves.toBe(true);
});
it("weist ein falsches Passwort ab", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("etwas-anderes", hash)).resolves.toBe(false);
});
it("erzeugt fuer dasselbe Passwort unterschiedliche Hashes", async () => {
// Unterschiedliches Salz – sonst waere an gleichen Hashes ablesbar,
// wer dasselbe Passwort benutzt.
const a = await hashPassword("ein-langes-testpasswort");
const b = await hashPassword("ein-langes-testpasswort");
expect(a).not.toBe(b);
});
it("haelt das erwartete Ablageformat ein", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
expect(hash).toMatch(/^scrypt\$\d+\$\d+\$\d+\$[\w-]+\$[\w-]+$/);
});
it("stuerzt bei kaputten oder fehlenden Hashes nicht ab", async () => {
await expect(verifyPassword("x", null)).resolves.toBe(false);
await expect(verifyPassword("x", "")).resolves.toBe(false);
await expect(verifyPassword("x", "voelliger-unsinn")).resolves.toBe(false);
await expect(verifyPassword("x", "scrypt$a$b$c$d$e")).resolves.toBe(false);
});
it("behandelt gleichwertige Unicode-Schreibweisen gleich", async () => {
// "é" als ein Zeichen und als e + Akzent kombiniert.
const hash = await hashPassword("passwort-café-lang");
await expect(verifyPassword("passwort-café-lang", hash)).resolves.toBe(true);
});
});
describe("validatePasswordStrength", () => {
it("nimmt ein ausreichend langes Passwort an", () => {
expect(validatePasswordStrength("ein-langes-testpasswort")).toBeNull();
});
it("weist zu kurze Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(PASSWORD_MIN_LENGTH - 1))).toMatch(
/mindestens/,
);
});
it("weist naheliegende Passwoerter ab", () => {
expect(validatePasswordStrength("password")).toBeTruthy();
expect(validatePasswordStrength("Passwort")).toBeTruthy();
});
it("weist reine Ziffernfolgen ab", () => {
expect(validatePasswordStrength("1234567890123")).toMatch(/Ziffern/);
});
it("weist uebermaessig lange Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
});
});
+11
View File
@@ -12,6 +12,17 @@ function trimmed(value: string | undefined): string | null {
return result ? result : null; return result ? result : null;
} }
import packageJson from "../../package.json";
/**
* Version aus der package.json – eine einzige Quelle. Vorher stand sie
* zusaetzlich in der Fusszeile und lief prompt auseinander.
*
* Bewusst als Standardimport: ein benannter Import aus einer JSON-Datei
* loest in Webpack eine Warnung aus.
*/
export const APP_VERSION: string = packageJson.version;
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */ /** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula"; export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
+12
View File
@@ -27,6 +27,16 @@ export const exportedNoteSchema = z.object({
createdAt: z.string().optional(), createdAt: z.string().optional(),
}); });
export const exportedTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
status: z.enum(["TODO", "DOING", "BLOCKED", "DONE"]).optional().default("TODO"),
position: z.number().int().min(0).optional().default(0),
dueDate: z.string().nullable().optional(),
labels: z.array(z.string().max(30)).max(8).optional().default([]),
createdAt: z.string().optional(),
});
export const exportedConnectionSchema = z.object({ export const exportedConnectionSchema = z.object({
name: z.string().max(80), name: z.string().max(80),
provider: z.enum(PROVIDERS), provider: z.enum(PROVIDERS),
@@ -51,6 +61,8 @@ export const exportedProjectSchema = z.object({
/** Nur zur Wiedererkennung – ohne Token. */ /** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(), connection: exportedConnectionSchema.nullable().optional(),
notes: z.array(exportedNoteSchema).max(500).optional().default([]), notes: z.array(exportedNoteSchema).max(500).optional().default([]),
tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
progressFromTasks: z.boolean().optional().default(false),
createdAt: z.string().optional(), createdAt: z.string().optional(),
}); });
+36 -2
View File
@@ -1,12 +1,13 @@
import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client"; import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
import { APP_NAME } from "@/lib/config"; import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status"; import { PRIORITY_META, statusOf } from "@/lib/status";
import { formatDateTime } from "@/lib/utils"; import { formatDate, formatDateTime } from "@/lib/utils";
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & { type ProjectForExport = Project & {
notes: Note[]; notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null; connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
}; };
@@ -40,6 +41,16 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
pinned: note.pinned, pinned: note.pinned,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
})), })),
tasks: project.tasks.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
position: task.position,
dueDate: task.dueDate?.toISOString() ?? null,
labels: task.labels,
createdAt: task.createdAt.toISOString(),
})),
progressFromTasks: project.progressFromTasks,
createdAt: project.createdAt.toISOString(), createdAt: project.createdAt.toISOString(),
}; };
} }
@@ -63,6 +74,7 @@ export async function loadProjectsForExport(
where: { ownerId, ...(projectId ? { id: projectId } : {}) }, where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -77,6 +89,13 @@ const PROVIDER_LABEL: Record<string, string> = {
GITEA: "Gitea", GITEA: "Gitea",
}; };
const TASK_LABEL: Record<string, string> = {
TODO: "Offen",
DOING: "In Arbeit",
BLOCKED: "Blockiert",
DONE: "Erledigt",
};
/** /**
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage * Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die * fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
@@ -131,6 +150,21 @@ export function toMarkdown(
lines.push(""); lines.push("");
} }
if (project.tasks.length) {
lines.push("## Aufgaben", "");
for (const status of ["TODO", "DOING", "BLOCKED", "DONE"] as const) {
const inColumn = project.tasks.filter((task) => task.status === status);
if (!inColumn.length) continue;
lines.push(`### ${TASK_LABEL[status]}`, "");
for (const task of inColumn) {
const box = status === "DONE" ? "[x]" : "[ ]";
const due = task.dueDate ? ` _(faellig ${formatDate(task.dueDate)})_` : "";
lines.push(`- ${box} ${task.title}${due}`);
}
lines.push("");
}
}
if (project.notes.length) { if (project.notes.length) {
lines.push("## Notizen", ""); lines.push("## Notizen", "");
for (const note of project.notes) { for (const note of project.notes) {
+106 -5
View File
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type { import type {
BranchInfo, BranchInfo,
CiInfo,
CiState,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
GitClient, GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef, RepoRef,
RepoSnapshotData, RepoSnapshotData,
TagInfo, TagInfo,
TicketInfo,
} from "./types"; } from "./types";
import { bucketCommitActivity } from "./types";
/** /**
* Gitea (und kompatible Forks wie Forgejo) – API v1. * Gitea (und kompatible Forks wie Forgejo) – API v1.
@@ -95,13 +99,32 @@ export class GiteaClient implements GitClient {
const repo = await getJson<GiteaRepo>(base, { headers }); const repo = await getJson<GiteaRepo>(base, { headers });
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([ const defaultBranch = repo.default_branch ?? "";
const [branches, tags, commits, releases, languages, pulls, issues, status] =
await Promise.all([
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }), getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }), getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=15&stat=false`, [], { headers }), // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }), getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=100`, [], { headers }), getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
headers,
}),
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
// ohnehin nicht kennen.
defaultBranch
? getJsonSafe<GiteaCombinedStatus>(
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
{} as GiteaCombinedStatus,
{ headers },
)
: Promise.resolve({} as GiteaCombinedStatus),
]); ]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
@@ -141,6 +164,38 @@ export class GiteaClient implements GitClient {
draft: Boolean(release.draft), draft: Boolean(release.draft),
})); }));
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
number: pull.number,
title: pull.title,
author: pull.user?.login ?? null,
createdAt: toIso(pull.created_at),
url: pull.html_url ?? null,
draft: Boolean(pull.draft),
labels: (pull.labels ?? []).map((label) => label.name),
}));
const mappedIssues: TicketInfo[] = issues.map((issue) => ({
number: issue.number,
title: issue.title,
author: issue.user?.login ?? null,
createdAt: toIso(issue.created_at),
url: issue.html_url ?? null,
draft: false,
labels: (issue.labels ?? []).map((label) => label.name),
}));
const latestStatus = status?.statuses?.[0];
const ci: CiInfo | null =
status?.state && status.state !== "" && status.statuses?.length
? {
state: mapCommitStatus(status.state),
name: latestStatus?.context ?? null,
branch: defaultBranch || null,
url: latestStatus?.target_url ?? null,
finishedAt: toIso(latestStatus?.updated_at),
}
: null;
return { return {
fullName: repo.full_name, fullName: repo.full_name,
description: repo.description ?? null, description: repo.description ?? null,
@@ -155,13 +210,31 @@ export class GiteaClient implements GitClient {
pushedAt: toIso(repo.updated_at), pushedAt: toIso(repo.updated_at),
branches: mappedBranches, branches: mappedBranches,
tags: mappedTags, tags: mappedTags,
commits: mappedCommits, commits: mappedCommits.slice(0, 15),
releases: mappedReleases, releases: mappedReleases,
languages: languages ?? {}, languages: languages ?? {},
pullRequests: mappedPulls,
issues: mappedIssues,
ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
} }
function mapCommitStatus(state: string): CiState {
switch (state) {
case "success":
return "success";
case "pending":
return "running";
case "failure":
case "error":
return "failure";
default:
return "pending";
}
}
// ── API-Typen ────────────────────────────────────────────────── // ── API-Typen ──────────────────────────────────────────────────
interface GiteaRepo { interface GiteaRepo {
@@ -220,6 +293,34 @@ interface GiteaRelease {
draft: boolean; draft: boolean;
} }
interface GiteaLabel {
name: string;
}
interface GiteaPull { interface GiteaPull {
id: number; number: number;
title: string;
html_url: string | null;
created_at: string | null;
draft?: boolean;
user?: { login: string } | null;
labels?: GiteaLabel[];
}
interface GiteaIssue {
number: number;
title: string;
html_url: string | null;
created_at: string | null;
user?: { login: string } | null;
labels?: GiteaLabel[];
}
interface GiteaCombinedStatus {
state?: string;
statuses?: Array<{
context?: string;
target_url?: string;
updated_at?: string;
}>;
} }
+106 -6
View File
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type { import type {
BranchInfo, BranchInfo,
CiInfo,
CiState,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
GitClient, GitClient,
@@ -10,8 +12,9 @@ import type {
RepoRef, RepoRef,
RepoSnapshotData, RepoSnapshotData,
TagInfo, TagInfo,
TicketInfo,
} from "./types"; } from "./types";
import { GitApiError } from "./types"; import { GitApiError, bucketCommitActivity } from "./types";
/** /**
* GitHub – funktioniert mit github.com und GitHub Enterprise. * GitHub – funktioniert mit github.com und GitHub Enterprise.
@@ -85,13 +88,24 @@ export class GitHubClient implements GitClient {
const repo = await getJson<GitHubRepo>(base, { headers }); const repo = await getJson<GitHubRepo>(base, { headers });
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([ const defaultBranch = repo.default_branch;
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
await Promise.all([
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }), getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }), getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=15`, [], { headers }), // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }), getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=100`, [], { headers }), getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
{},
{ headers },
),
]); ]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
@@ -131,10 +145,43 @@ export class GitHubClient implements GitClient {
draft: Boolean(release.draft), draft: Boolean(release.draft),
})); }));
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
number: pull.number,
title: pull.title,
author: pull.user?.login ?? null,
createdAt: toIso(pull.created_at),
url: pull.html_url ?? null,
draft: Boolean(pull.draft),
labels: (pull.labels ?? []).map((label) => label.name),
}));
const mappedIssues: TicketInfo[] = issues
.filter((issue) => !issue.pull_request)
.map((issue) => ({
number: issue.number,
title: issue.title,
author: issue.user?.login ?? null,
createdAt: toIso(issue.created_at),
url: issue.html_url ?? null,
draft: false,
labels: (issue.labels ?? []).map((label) => label.name),
}));
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen. // open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
const openPullRequests = pulls.length; const openPullRequests = pulls.length;
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests); const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
const run = ciRuns?.workflow_runs?.[0];
const ci: CiInfo | null = run
? {
state: mapRunState(run.status, run.conclusion),
name: run.name ?? null,
branch: run.head_branch ?? null,
url: run.html_url ?? null,
finishedAt: toIso(run.updated_at),
}
: null;
return { return {
fullName: repo.full_name, fullName: repo.full_name,
description: repo.description ?? null, description: repo.description ?? null,
@@ -149,13 +196,36 @@ export class GitHubClient implements GitClient {
pushedAt: toIso(repo.pushed_at), pushedAt: toIso(repo.pushed_at),
branches: mappedBranches, branches: mappedBranches,
tags: mappedTags, tags: mappedTags,
commits: mappedCommits, // Angezeigt werden die letzten 15; die uebrigen dienten der Kurve.
commits: mappedCommits.slice(0, 15),
releases: mappedReleases, releases: mappedReleases,
languages: languages ?? {}, languages: languages ?? {},
pullRequests: mappedPulls,
issues: mappedIssues,
ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
} }
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
function mapRunState(status: string | null, conclusion: string | null): CiState {
if (status === "queued" || status === "waiting" || status === "pending") return "pending";
if (status === "in_progress") return "running";
switch (conclusion) {
case "success":
return "success";
case "cancelled":
return "cancelled";
case "failure":
case "timed_out":
case "startup_failure":
return "failure";
default:
return conclusion ? "failure" : "pending";
}
}
// ── API-Typen (nur die tatsaechlich genutzten Felder) ────────── // ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
interface GitHubRepo { interface GitHubRepo {
@@ -206,6 +276,36 @@ interface GitHubRelease {
draft: boolean; draft: boolean;
} }
interface GitHubLabel {
name: string;
}
interface GitHubPull { interface GitHubPull {
id: number; number: number;
title: string;
html_url: string | null;
created_at: string | null;
draft?: boolean;
user?: { login: string } | null;
labels?: GitHubLabel[];
}
interface GitHubIssue {
number: number;
title: string;
html_url: string | null;
created_at: string | null;
user?: { login: string } | null;
labels?: GitHubLabel[];
/** Gesetzt, wenn der Eintrag in Wahrheit ein Pull Request ist. */
pull_request?: unknown;
}
interface GitHubRun {
name: string | null;
status: string | null;
conclusion: string | null;
head_branch: string | null;
html_url: string | null;
updated_at: string | null;
} }
+93 -4
View File
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type { import type {
BranchInfo, BranchInfo,
CiInfo,
CiState,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
GitClient, GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef, RepoRef,
RepoSnapshotData, RepoSnapshotData,
TagInfo, TagInfo,
TicketInfo,
} from "./types"; } from "./types";
import { bucketCommitActivity } from "./types";
/** /**
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4). * GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
@@ -80,15 +84,25 @@ export class GitLabClient implements GitClient {
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers }); const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([ const defaultBranch = project.default_branch ?? "";
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
await Promise.all([
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }), getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }), getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=15`, [], { headers }), // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }), getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }), getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=100`, [], { getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
headers, headers,
}), }),
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
getJsonSafe<GitLabPipeline[]>(
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
[],
{ headers },
),
]); ]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({ const mappedBranches: BranchInfo[] = branches.map((branch) => ({
@@ -128,6 +142,37 @@ export class GitLabClient implements GitClient {
draft: false, draft: false,
})); }));
const mappedMergeRequests: TicketInfo[] = mergeRequests.map((entry) => ({
number: entry.iid,
title: entry.title,
author: entry.author?.username ?? null,
createdAt: toIso(entry.created_at),
url: entry.web_url ?? null,
draft: Boolean(entry.draft),
labels: entry.labels ?? [],
}));
const mappedIssues: TicketInfo[] = issues.map((entry) => ({
number: entry.iid,
title: entry.title,
author: entry.author?.username ?? null,
createdAt: toIso(entry.created_at),
url: entry.web_url ?? null,
draft: false,
labels: entry.labels ?? [],
}));
const pipeline = pipelines[0];
const ci: CiInfo | null = pipeline
? {
state: mapPipelineState(pipeline.status),
name: pipeline.name ?? `Pipeline #${pipeline.id}`,
branch: pipeline.ref ?? null,
url: pipeline.web_url ?? null,
finishedAt: toIso(pipeline.updated_at),
}
: null;
return { return {
fullName: project.path_with_namespace, fullName: project.path_with_namespace,
description: project.description ?? null, description: project.description ?? null,
@@ -144,13 +189,33 @@ export class GitLabClient implements GitClient {
pushedAt: toIso(project.last_activity_at), pushedAt: toIso(project.last_activity_at),
branches: mappedBranches, branches: mappedBranches,
tags: mappedTags, tags: mappedTags,
commits: mappedCommits, commits: mappedCommits.slice(0, 15),
releases: mappedReleases, releases: mappedReleases,
languages: languages ?? {}, languages: languages ?? {},
pullRequests: mappedMergeRequests,
issues: mappedIssues,
ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
}; };
} }
} }
function mapPipelineState(status: string | null): CiState {
switch (status) {
case "success":
return "success";
case "running":
return "running";
case "failed":
return "failure";
case "canceled":
case "skipped":
return "cancelled";
default:
return "pending";
}
}
// ── API-Typen ────────────────────────────────────────────────── // ── API-Typen ──────────────────────────────────────────────────
interface GitLabProject { interface GitLabProject {
@@ -205,5 +270,29 @@ interface GitLabRelease {
} }
interface GitLabMergeRequest { interface GitLabMergeRequest {
iid: number;
title: string;
created_at: string | null;
web_url: string | null;
draft?: boolean;
labels?: string[];
author?: { username: string } | null;
}
interface GitLabIssue {
iid: number;
title: string;
created_at: string | null;
web_url: string | null;
labels?: string[];
author?: { username: string } | null;
}
interface GitLabPipeline {
id: number; id: number;
name?: string | null;
status: string | null;
ref: string | null;
web_url: string | null;
updated_at: string | null;
} }
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { guessProvider } from "./meta";
import { parseRepoUrl } from "./index";
import { ACTIVITY_DAYS, bucketCommitActivity } from "./types";
describe("parseRepoUrl", () => {
it("zerlegt eine gewoehnliche https-URL", () => {
expect(parseRepoUrl("https://github.com/vercel/swr")).toMatchObject({
host: "github.com",
owner: "vercel",
name: "swr",
guessedProvider: "GITHUB",
});
});
it("entfernt die Endung .git", () => {
expect(parseRepoUrl("https://github.com/vercel/swr.git")?.name).toBe("swr");
});
it("versteht SSH-Adressen", () => {
expect(parseRepoUrl("git@gitlab.com:gruppe/projekt.git")).toMatchObject({
host: "gitlab.com",
owner: "gruppe",
name: "projekt",
guessedProvider: "GITLAB",
});
});
it("behaelt verschachtelte GitLab-Gruppen", () => {
expect(parseRepoUrl("https://gitlab.com/gruppe/untergruppe/projekt")).toMatchObject({
owner: "gruppe/untergruppe",
name: "projekt",
});
});
it("schneidet GitLab-Oberflaechenpfade ab dem /-/ ab", () => {
expect(parseRepoUrl("https://gitlab.com/gruppe/projekt/-/tree/main")).toMatchObject({
owner: "gruppe",
name: "projekt",
});
});
it("kommt mit einer eigenen Gitea-Instanz zurecht", () => {
expect(parseRepoUrl("https://git.meine-firma.de/team/repo")).toMatchObject({
baseUrl: "https://git.meine-firma.de",
owner: "team",
name: "repo",
guessedProvider: "GITEA",
});
});
it("gibt null zurueck, wenn kein Repository erkennbar ist", () => {
expect(parseRepoUrl("https://github.com/nurbenutzer")).toBeNull();
expect(parseRepoUrl("")).toBeNull();
expect(parseRepoUrl("kein-url-artiger-text")).toBeNull();
});
});
describe("guessProvider", () => {
it("erkennt die grossen beiden am Hostnamen", () => {
expect(guessProvider("github.com")).toBe("GITHUB");
expect(guessProvider("gitlab.example.org")).toBe("GITLAB");
});
it("nimmt bei allem anderen Gitea an", () => {
expect(guessProvider("git.meine-firma.de")).toBe("GITEA");
});
});
describe("bucketCommitActivity", () => {
const dayMs = 24 * 60 * 60 * 1000;
const daysAgo = (days: number) => new Date(Date.now() - days * dayMs).toISOString();
it("liefert ohne Commits den vollen Zeitraum", () => {
expect(bucketCommitActivity([])).toHaveLength(ACTIVITY_DAYS);
});
it("sortiert den juengsten Commit ans Ende", () => {
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(0)]);
expect(buckets).toHaveLength(ACTIVITY_DAYS);
expect(buckets[ACTIVITY_DAYS - 1]).toBe(1);
});
it("zaehlt mehrere Commits desselben Tages zusammen", () => {
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(2), daysAgo(2), daysAgo(2)]);
expect(buckets[buckets.length - 3]).toBe(3);
});
it("kuerzt auf den belegten Zeitraum, statt Unbekanntes als Null zu zeigen", () => {
// Reicht die geholte Historie nur fuenf Tage zurueck, duerfen die
// 25 Tage davor nicht als "keine Commits" erscheinen.
const buckets = bucketCommitActivity([daysAgo(4), daysAgo(0)]);
expect(buckets).toHaveLength(5);
expect(buckets[0]).toBe(1);
expect(buckets[4]).toBe(1);
});
it("ignoriert unlesbare Werte", () => {
const buckets = bucketCommitActivity([null, "voelliger Unsinn"]);
expect(buckets).toHaveLength(ACTIVITY_DAYS);
expect(buckets.every((value) => value === 0)).toBe(true);
});
it("laesst aeltere Commits den vollen Zeitraum aufspannen", () => {
const buckets = bucketCommitActivity([daysAgo(100), daysAgo(1)]);
expect(buckets).toHaveLength(ACTIVITY_DAYS);
expect(buckets[ACTIVITY_DAYS - 2]).toBe(1);
});
});
+70
View File
@@ -47,6 +47,29 @@ export interface ReleaseInfo {
draft: boolean; draft: boolean;
} }
/** Ein offener Pull Request, Merge Request oder ein Issue. */
export interface TicketInfo {
number: number;
title: string;
author: string | null;
createdAt: string | null;
url: string | null;
draft: boolean;
labels: string[];
}
/** Ergebnis des letzten CI-Laufs auf dem Default-Branch. */
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
export interface CiInfo {
state: CiState;
/** Name des Workflows bzw. der Pipeline, falls die API ihn liefert. */
name: string | null;
branch: string | null;
url: string | null;
finishedAt: string | null;
}
export interface RepoOverview { export interface RepoOverview {
fullName: string; fullName: string;
description: string | null; description: string | null;
@@ -67,6 +90,53 @@ export interface RepoSnapshotData extends RepoOverview {
commits: CommitInfo[]; commits: CommitInfo[];
releases: ReleaseInfo[]; releases: ReleaseInfo[];
languages: Record<string, number>; languages: Record<string, number>;
pullRequests: TicketInfo[];
issues: TicketInfo[];
ci: CiInfo | null;
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
commitActivity: number[];
}
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
export const ACTIVITY_DAYS = 30;
/**
* Verteilt Commit-Zeitpunkte auf Tagesbuckets. Bewusst aus den ohnehin
* geholten Commits berechnet statt ueber eigene Statistik-Endpunkte: die
* gibt es nicht bei allen drei Systemen, und bei GitHub antworten sie beim
* ersten Aufruf mit 202 und ohne Daten.
*
* Wichtig fuer die Ehrlichkeit der Kurve: geholt wird nur eine Seite
* Commits. Liegt der aelteste davon noch innerhalb des Zeitraums, wissen wir
* ueber die Tage davor schlicht nichts – dort eine Null zu zeichnen waere
* falsch. Deshalb wird die Kurve auf den tatsaechlich belegten Zeitraum
* gekuerzt.
*/
export function bucketCommitActivity(dates: Array<string | null>): number[] {
const buckets = new Array<number>(ACTIVITY_DAYS).fill(0);
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
let oldestDaysAgo = -1;
for (const value of dates) {
if (!value) continue;
const time = Date.parse(value);
if (Number.isNaN(time)) continue;
const daysAgo = Math.floor((now - time) / dayMs);
if (daysAgo < 0) continue;
if (daysAgo > oldestDaysAgo) oldestDaysAgo = daysAgo;
if (daysAgo >= ACTIVITY_DAYS) continue;
buckets[ACTIVITY_DAYS - 1 - daysAgo] += 1;
}
// Reicht die Historie nicht bis an den Rand des Zeitraums, nur den
// belegten Ausschnitt zurueckgeben.
if (oldestDaysAgo >= 0 && oldestDaysAgo < ACTIVITY_DAYS - 1) {
return buckets.slice(ACTIVITY_DAYS - 1 - oldestDaysAgo);
}
return buckets;
} }
export interface RepoListItem { export interface RepoListItem {
+213
View File
@@ -0,0 +1,213 @@
import { createHmac } from "node:crypto";
import nodemailer from "nodemailer";
import type { NotificationTarget } from "@prisma/client";
import { tryDecryptSecret } from "@/lib/crypto";
import { APP_NAME, APP_URL } from "@/lib/config";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import type { DeliveryResult, NotificationMessage } from "./types";
/**
* Zustellung einer Nachricht ueber den gewaehlten Weg.
*
* Zwei Dinge gelten fuer alle Wege:
* - Die Zieladresse ist eine Benutzereingabe und wird serverseitig
* abgerufen. Sie laeuft deshalb durch denselben SSRF-Schutz wie die
* Git-Verbindungen. Ein ntfy im eigenen Netz bleibt damit erlaubt,
* Loopback und Metadaten-Endpunkte nicht.
* - Fehler werden zurueckgegeben, nicht geworfen. Eine kaputte
* Benachrichtigung darf einen Abgleich nicht scheitern lassen.
*/
const TIMEOUT_MS = Number(process.env.NOTIFY_TIMEOUT_MS ?? 10_000);
async function postJson(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<DeliveryResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" ? body : JSON.stringify(body),
signal: controller.signal,
// Ein Redirect koennte auf ein anderes Ziel fuehren, als geprueft
// wurde – deshalb gar nicht erst folgen.
redirect: "manual",
});
if (!response.ok && response.status !== 0) {
return { ok: false, message: `Antwort ${response.status} von ${new URL(url).host}` };
}
return { ok: true, message: `Zugestellt (${response.status})` };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` };
}
return { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
async function sendWebhook(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const payload = {
source: APP_NAME,
event: message.event,
title: message.title,
body: message.body,
url: message.url,
project: message.project,
at: message.at,
};
const raw = JSON.stringify(payload);
const headers: Record<string, string> = { "User-Agent": `${APP_NAME}-Notify` };
// Mit Geheimnis wird der Rumpf signiert, damit der Empfaenger pruefen
// kann, dass die Nachricht wirklich von hier kommt.
const secret = tryDecryptSecret(target.secretCipher);
if (secret) {
headers["X-Nebula-Signature"] = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;
}
return postJson(target.target, raw, headers);
}
async function sendNtfy(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const headers: Record<string, string> = {
Title: encodeHeader(message.title),
Priority: message.priority === "high" ? "high" : "default",
Tags: message.event === "TEST" ? "bell" : "warning",
};
if (message.url) headers.Click = message.url;
const secret = tryDecryptSecret(target.secretCipher);
if (secret) headers.Authorization = `Bearer ${secret}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(target.target, {
method: "POST",
// ntfy nimmt den Rumpf als reinen Text.
headers: { "Content-Type": "text/plain; charset=utf-8", ...headers },
body: message.body,
signal: controller.signal,
redirect: "manual",
});
if (!response.ok) {
return { ok: false, message: `Antwort ${response.status} von ntfy` };
}
return { ok: true, message: "An ntfy zugestellt" };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { ok: false, message: `Zeitueberschreitung nach ${TIMEOUT_MS} ms` };
}
return { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
/**
* Kopfzeilen duerfen nur Latin-1 enthalten. Emojis werden deshalb entfernt,
* nicht durch Fragezeichen ersetzt – aus "🔴 Projekt: CI ist rot" wuerde
* sonst "?? Projekt: CI ist rot". Das Sinnbild traegt ohnehin der
* Tags-Header, den ntfy selbst in ein Symbol umsetzt.
*/
function encodeHeader(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[^\x20-\xFF]/g, "").replace(/\s+/g, " ").trim();
}
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
user?: string;
password?: string;
from: string;
}
/** Liest die SMTP-Einstellungen aus der Umgebung, falls vorhanden. */
export function smtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_HOST?.trim();
if (!host) return null;
const port = Number(process.env.SMTP_PORT ?? 587);
return {
host,
port,
// Port 465 spricht von Anfang an TLS, 587 steigt per STARTTLS um.
secure: process.env.SMTP_SECURE
? /^(1|true|yes|on)$/i.test(process.env.SMTP_SECURE)
: port === 465,
user: process.env.SMTP_USER?.trim() || undefined,
password: process.env.SMTP_PASSWORD || undefined,
from: process.env.SMTP_FROM?.trim() || `${APP_NAME} <nebula@localhost>`,
};
}
export const emailAvailable = () => smtpConfig() !== null;
async function sendEmail(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
const config = smtpConfig();
if (!config) {
return { ok: false, message: "Kein SMTP hinterlegt – SMTP_HOST in der .env setzen." };
}
try {
const transport = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.password } : undefined,
connectionTimeout: TIMEOUT_MS,
greetingTimeout: TIMEOUT_MS,
});
const zeilen = [message.body];
if (message.url) zeilen.push("", message.url);
zeilen.push("", `— ${APP_NAME}, ${APP_URL}`);
await transport.sendMail({
from: config.from,
to: target.target,
subject: message.title,
text: zeilen.join("\n"),
});
return { ok: true, message: `An ${target.target} versendet` };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
/** Stellt eine Nachricht ueber den Weg des Ziels zu. */
export async function deliver(
target: NotificationTarget,
message: NotificationMessage,
): Promise<DeliveryResult> {
if (target.channel === "EMAIL") return sendEmail(target, message);
// Webhook und ntfy rufen eine vom Benutzer angegebene Adresse auf.
try {
await assertAllowedUrl(target.target);
} catch (error) {
if (error instanceof BlockedHostError) return { ok: false, message: error.message };
throw error;
}
return target.channel === "NTFY" ? sendNtfy(target, message) : sendWebhook(target, message);
}
+159
View File
@@ -0,0 +1,159 @@
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client";
import { APP_NAME, APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
export * from "./types";
export { emailAvailable, smtpConfig } from "./channels";
/**
* Entscheidet nach einem Abgleich, worueber zu benachrichtigen ist, und
* stellt zu.
*
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
* Der zuletzt gemeldete Zustand steht am Projekt.
*
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
* darf den Abgleich nicht scheitern lassen.
*/
export async function notifyAfterSync(projectId: string): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { snapshot: true },
});
if (!project?.ownerId) return;
const faellig = pendingEvents(project, project.snapshot);
if (faellig.length === 0) return;
const ziele = await prisma.notificationTarget.findMany({
where: {
userId: project.ownerId,
isActive: true,
events: { hasSome: faellig.map((entry) => entry.event) },
},
});
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
data: {
notifiedSyncError: project.syncError,
notifiedCiState: project.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(project.snapshot),
},
});
if (ziele.length === 0) return;
for (const eintrag of faellig) {
const passende = ziele.filter((ziel) => ziel.events.includes(eintrag.event));
for (const ziel of passende) {
const ergebnis = await deliver(ziel, eintrag.message);
await prisma.notificationTarget.update({
where: { id: ziel.id },
data: {
lastSentAt: new Date(),
lastOk: ergebnis.ok,
lastMessage: ergebnis.message,
},
});
}
}
} catch (error) {
console.error("[nebula] Benachrichtigung fehlgeschlagen:", error);
}
}
function currentRelease(snapshot: RepoSnapshot | null): string | null {
if (!snapshot) return null;
return snapshot.latestRelease ?? snapshot.latestTag ?? null;
}
/**
* Vergleicht den aktuellen Stand mit dem zuletzt gemeldeten.
* Exportiert, weil hier die eigentliche Entscheidung faellt – siehe Tests.
*/
export function pendingEvents(
project: Project,
snapshot: RepoSnapshot | null,
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
const projektUrl = `${APP_URL}/projects/${project.id}`;
const bezug = { id: project.id, name: project.name, slug: project.slug };
const jetzt = new Date().toISOString();
if (project.syncError && project.syncError !== project.notifiedSyncError) {
treffer.push({
event: "SYNC_FAILED",
message: {
event: "SYNC_FAILED",
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
body: project.syncError,
priority: "high",
url: projektUrl,
project: bezug,
at: jetzt,
},
});
}
const ci = snapshot?.ciState ?? null;
if (ci === "failure" && ci !== project.notifiedCiState) {
treffer.push({
event: "CI_FAILED",
message: {
event: "CI_FAILED",
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`,
body:
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
priority: "high",
url: snapshot?.ciUrl ?? projektUrl,
project: bezug,
at: jetzt,
},
});
}
const release = currentRelease(snapshot);
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
// vorher schon einmal etwas festgehalten wurde.
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
treffer.push({
event: "NEW_RELEASE",
message: {
event: "NEW_RELEASE",
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
priority: "normal",
url: snapshot?.htmlUrl ?? projektUrl,
project: bezug,
at: jetzt,
},
});
}
return treffer;
}
/** Nachricht fuer den Knopf "Testen" an einem Ziel. */
export function testMessage(): NotificationMessage {
return {
event: "TEST",
title: `🔔 ${APP_NAME}: Testnachricht`,
body:
"Wenn du das liest, ist der Zustellweg eingerichtet. " +
"Echte Meldungen kommen nur bei einer Aenderung – nicht bei jedem Abgleich.",
priority: "normal",
url: APP_URL,
project: null,
at: new Date().toISOString(),
};
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { Project, RepoSnapshot } from "@prisma/client";
import { pendingEvents } from "./index";
/**
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
*/
function projekt(overrides: Partial<Project> = {}): Project {
return {
id: "p1",
name: "Testprojekt",
slug: "testprojekt",
syncError: null,
notifiedSyncError: null,
notifiedCiState: null,
notifiedRelease: null,
...overrides,
} as Project;
}
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
return {
ciState: null,
ciBranch: "main",
ciName: null,
ciUrl: null,
latestTag: null,
latestRelease: null,
htmlUrl: null,
...overrides,
} as RepoSnapshot;
}
const events = (p: Project, s: RepoSnapshot | null) =>
pendingEvents(p, s).map((entry) => entry.event);
describe("Abgleichsfehler", () => {
it("meldet einen neuen Fehler", () => {
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
});
it("meldet denselben Fehler kein zweites Mal", () => {
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
});
it("meldet, wenn sich der Fehler aendert", () => {
expect(
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
).toEqual(["SYNC_FAILED"]);
});
it("meldet nichts, wenn kein Fehler vorliegt", () => {
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
});
});
describe("CI", () => {
it("meldet den Wechsel auf rot", () => {
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
});
it("meldet dauerhaft rote CI nur einmal", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
).toEqual([]);
});
it("meldet nicht, wenn die CI gruen ist", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
).toEqual([]);
});
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
expect(
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
).toEqual(["CI_FAILED"]);
});
});
describe("Release", () => {
it("schweigt beim allerersten Abgleich", () => {
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
expect(
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
).toEqual([]);
});
it("meldet eine neue Version", () => {
expect(
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual(["NEW_RELEASE"]);
});
it("meldet dieselbe Version nicht erneut", () => {
expect(
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual([]);
});
it("zieht den Releasenamen dem Tag vor", () => {
const treffer = pendingEvents(
projekt({ notifiedRelease: "alt" }),
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
);
expect(treffer[0].message.title).toContain("Zweite Ausgabe");
});
});
describe("mehrere Anlaesse gleichzeitig", () => {
it("meldet Fehler und rote CI zusammen", () => {
expect(
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
});
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
const treffer = pendingEvents(projekt({ syncError: "404" }), null);
expect(treffer[0].message.project?.slug).toBe("testprojekt");
expect(treffer[0].message.url).toContain("/projects/p1");
expect(treffer[0].message.priority).toBe("high");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { daysSince, isStaleDue } from "./stale";
/**
* Die Entscheidung, wann ein Projekt als eingeschlafen gilt – und vor allem,
* wann es *nicht* erneut gemeldet wird.
*/
const TAG = 86_400_000;
const jetzt = new Date("2026-07-26T12:00:00Z");
const vorTagen = (tage: number) => new Date(jetzt.getTime() - tage * TAG);
/** Schwelle: 60 Tage. */
const cutoff = vorTagen(60);
describe("isStaleDue", () => {
it("meldet ein Projekt, dessen letzter Commit laenger zurueckliegt", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nichts innerhalb der Frist", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(30), cutoff)).toBe(false);
});
it("meldet nichts genau an der Schwelle", () => {
expect(isStaleDue({ notifiedStaleAt: null }, cutoff, cutoff)).toBe(false);
});
it("meldet nichts ohne bekannten letzten Commit", () => {
// Ein Projekt ohne Repository oder ohne Snapshot laesst sich nicht
// beurteilen – lieber schweigen als raten.
expect(isStaleDue({ notifiedStaleAt: null }, null, cutoff)).toBe(false);
});
it("meldet dasselbe Projekt kein zweites Mal", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(5) }, vorTagen(90), cutoff)).toBe(false);
});
it("meldet erneut, wenn zwischendurch gearbeitet wurde und es wieder still ist", () => {
// Gemeldet vor 200 Tagen, danach ein Commit vor 90 Tagen, seitdem Ruhe.
expect(isStaleDue({ notifiedStaleAt: vorTagen(200) }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nicht erneut, wenn der Commit vor der letzten Meldung lag", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(10) }, vorTagen(90), cutoff)).toBe(false);
});
});
describe("daysSince", () => {
it("zaehlt volle Tage", () => {
expect(daysSince(vorTagen(60), jetzt)).toBe(60);
expect(daysSince(vorTagen(1), jetzt)).toBe(1);
});
it("rundet angebrochene Tage ab", () => {
expect(daysSince(new Date(jetzt.getTime() - 1.9 * TAG), jetzt)).toBe(1);
});
it("liefert null fuer soeben", () => {
expect(daysSince(jetzt, jetzt)).toBe(0);
});
});
+128
View File
@@ -0,0 +1,128 @@
import type { Project, RepoSnapshot } from "@prisma/client";
import { APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { statusOf } from "@/lib/status";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
/**
* Meldet Projekte, in denen laenger nichts passiert ist.
*
* Anders als die uebrigen Anlaesse haengt dieser an keinem Ereignis – es
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
* einmal am Tag.
*
* Massstab ist ausschliesslich der letzte Commit im verknuepften
* Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
* Projekte ohne Repository bleiben folglich aussen vor.
*/
export interface StaleReport {
at: string;
checked: number;
stale: number;
notified: number;
thresholdDays: number;
}
/** Entscheidet, ob ein Projekt jetzt zu melden ist. */
export function isStaleDue(
project: Pick<Project, "notifiedStaleAt">,
lastCommitAt: Date | null,
cutoff: Date,
): boolean {
// Ohne bekannten letzten Commit gibt es nichts zu beurteilen.
if (!lastCommitAt) return false;
// Noch innerhalb der Frist.
if (lastCommitAt >= cutoff) return false;
// Noch nie gemeldet: jetzt.
if (!project.notifiedStaleAt) return true;
// Schon gemeldet – erneut nur, wenn danach wieder ein Commit kam und es
// seitdem wieder still geworden ist.
return lastCommitAt > project.notifiedStaleAt;
}
/** Wie viele volle Tage seit dem letzten Commit vergangen sind. */
export function daysSince(date: Date, now: Date = new Date()): number {
return Math.floor((now.getTime() - date.getTime()) / 86_400_000);
}
function message(
project: Project,
snapshot: RepoSnapshot | null,
tage: number,
): NotificationMessage {
return {
event: "PROJECT_STALE",
title: `${EVENT_META.PROJECT_STALE.emoji} ${project.name}: seit ${tage} Tagen kein Commit`,
body:
`Der letzte Commit liegt ${tage} Tage zurueck. ` +
`Status: ${statusOf(project.status).label}. Vielleicht abschliessen oder archivieren?`,
priority: "normal",
url: snapshot?.htmlUrl ?? `${APP_URL}/projects/${project.id}`,
project: { id: project.id, name: project.name, slug: project.slug },
at: new Date().toISOString(),
};
}
/**
* Ein Durchlauf. Meldet je Projekt hoechstens einmal und schreibt den
* Merkstand fort – auch dann, wenn niemand zuhoert.
*/
export async function runStaleCheck(thresholdDays: number): Promise<StaleReport> {
const now = new Date();
const cutoff = new Date(now.getTime() - thresholdDays * 86_400_000);
const kandidaten = await prisma.project.findMany({
where: {
// Fertige und archivierte Projekte duerfen ruhen.
status: { notIn: ["DONE", "ARCHIVED"] },
ownerId: { not: null },
snapshot: { isNot: null },
},
include: { snapshot: true },
});
const faellig = kandidaten.filter((project) =>
isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff),
);
let zugestellt = 0;
for (const project of faellig) {
const lastCommitAt = project.snapshot?.lastCommitAt;
if (!lastCommitAt || !project.ownerId) continue;
const ziele = await prisma.notificationTarget.findMany({
where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } },
});
for (const ziel of ziele) {
const ergebnis = await deliver(
ziel,
message(project, project.snapshot, daysSince(lastCommitAt, now)),
);
zugestellt += 1;
await prisma.notificationTarget.update({
where: { id: ziel.id },
data: { lastSentAt: now, lastOk: ergebnis.ok, lastMessage: ergebnis.message },
});
}
// Auch ohne Ziel vermerken, sonst kaeme beim ersten eingerichteten Ziel
// die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
data: { notifiedStaleAt: now },
});
}
return {
at: now.toISOString(),
checked: kandidaten.length,
stale: faellig.length,
notified: zugestellt,
thresholdDays,
};
}
+72
View File
@@ -0,0 +1,72 @@
import type { NotificationEvent } from "@prisma/client";
/** Reihenfolge fuer die Oberflaeche. */
export const NOTIFICATION_EVENTS: NotificationEvent[] = [
"SYNC_FAILED",
"CI_FAILED",
"NEW_RELEASE",
"PROJECT_STALE",
];
export const EVENT_META: Record<
NotificationEvent,
{ label: string; description: string; emoji: string }
> = {
SYNC_FAILED: {
label: "Abgleich fehlgeschlagen",
description: "Das Git-System war nicht erreichbar oder hat abgelehnt.",
emoji: "⚠️",
},
CI_FAILED: {
label: "CI rot",
description: "Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen.",
emoji: "🔴",
},
NEW_RELEASE: {
label: "Neues Release",
description: "Im Repository ist ein neuer Tag oder ein Release aufgetaucht.",
emoji: "🏷️",
},
PROJECT_STALE: {
label: "Projekt steht still",
description: "Seit laengerem kein Commit mehr. Die Schwelle setzt ein Administrator.",
emoji: "🕸️",
},
};
/** Eine fertig formulierte Nachricht, unabhaengig vom Zustellweg. */
export interface NotificationMessage {
event: NotificationEvent | "TEST";
title: string;
body: string;
/** Fuer ntfy: hoehere Zahl bedeutet auffaelliger. */
priority: "normal" | "high";
/** Link zurueck ins Dashboard oder ins Repository. */
url: string | null;
project: { id: string; name: string; slug: string } | null;
at: string;
}
export interface DeliveryResult {
ok: boolean;
message: string;
}
/**
* Felder eines Ziels, die an den Browser gehen duerfen – ohne Chiffrat.
* Liegt hier und nicht in der Route: Next laesst in route.ts nur bestimmte
* Exporte zu und bricht den Bau bei allem anderen ab.
*/
export const targetSafeSelect = {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
createdAt: true,
} as const;
+8 -2
View File
@@ -49,7 +49,10 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect }, connection: { select: connectionSafeSelect },
snapshot: true, snapshot: true,
_count: { select: { notes: true } }, _count: { select: { notes: true, tasks: true } },
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
// Karte, ohne die Aufgaben selbst mitzuladen.
tasks: { where: { status: "DONE" }, select: { id: true } },
}); });
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
@@ -58,5 +61,8 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
notes: { notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
}, },
_count: { select: { notes: true } }, tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }],
},
_count: { select: { notes: true, tasks: true } },
}); });
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it } from "vitest";
import { BlockedHostError, assertAllowedUrl } from "./ssrf";
/**
* Geprueft werden IP-Literale und die Formpruefung – beides kommt ohne
* Netzzugriff aus. Die Namensaufloesung deckt der Durchlauf gegen echte
* Instanzen ab.
*/
afterEach(() => {
delete process.env.GIT_ALLOW_LOOPBACK;
delete process.env.GIT_BLOCK_PRIVATE;
delete process.env.GIT_ALLOWED_HOSTS;
});
async function blockedReason(url: string): Promise<string> {
try {
await assertAllowedUrl(url);
return "NICHT BLOCKIERT";
} catch (error) {
if (error instanceof BlockedHostError) return error.message;
throw error;
}
}
describe("assertAllowedUrl – gesperrte Ziele", () => {
it("blockt Loopback in IPv4 und IPv6", async () => {
expect(await blockedReason("http://127.0.0.1:5432")).toMatch(/Loopback/);
expect(await blockedReason("http://[::1]:3000")).toMatch(/Loopback|aufgeloest/);
});
it("blockt den Metadaten-Endpunkt der Cloud-Anbieter", async () => {
expect(await blockedReason("http://169.254.169.254/latest/meta-data/")).toMatch(
/Link-Local|Metadaten/,
);
});
it("blockt die unspezifizierte Adresse", async () => {
expect(await blockedReason("http://0.0.0.0")).toMatch(/unspezifiziert/);
});
it("blockt Carrier-Grade-NAT und Multicast", async () => {
expect(await blockedReason("http://100.64.0.1")).toMatch(/Carrier/);
expect(await blockedReason("http://239.0.0.1")).toMatch(/Multicast|reserviert/);
});
it("blockt IPv4-gemappte IPv6-Adressen auf Loopback", async () => {
expect(await blockedReason("http://[::ffff:127.0.0.1]")).toMatch(/Loopback|aufgeloest/);
});
});
describe("assertAllowedUrl – Formpruefung", () => {
it("laesst nur http und https zu", async () => {
expect(await blockedReason("file:///etc/passwd")).toMatch(/Nur http/);
expect(await blockedReason("gopher://example.com")).toMatch(/Nur http/);
});
it("weist Zugangsdaten in der URL ab", async () => {
expect(await blockedReason("https://nutzer:geheim@8.8.8.8")).toMatch(/Zugangsdaten/);
});
it("weist unlesbare URLs ab", async () => {
expect(await blockedReason("kein-url-artiger-text")).toMatch(/Ungueltige URL/);
});
});
describe("assertAllowedUrl – erlaubte Ziele", () => {
it("laesst oeffentliche Adressen durch", async () => {
await expect(assertAllowedUrl("https://140.82.121.4")).resolves.toBeInstanceOf(URL);
});
it("laesst das private LAN standardmaessig zu", async () => {
await expect(assertAllowedUrl("http://192.168.1.10:3000")).resolves.toBeInstanceOf(URL);
await expect(assertAllowedUrl("http://10.0.0.5")).resolves.toBeInstanceOf(URL);
await expect(assertAllowedUrl("http://172.16.0.1")).resolves.toBeInstanceOf(URL);
});
});
describe("assertAllowedUrl – Schalter", () => {
it("GIT_ALLOW_LOOPBACK gibt Loopback frei", async () => {
process.env.GIT_ALLOW_LOOPBACK = "true";
await expect(assertAllowedUrl("http://127.0.0.1:3000")).resolves.toBeInstanceOf(URL);
});
it("GIT_BLOCK_PRIVATE sperrt das private Netz", async () => {
process.env.GIT_BLOCK_PRIVATE = "true";
expect(await blockedReason("http://192.168.1.10")).toMatch(/privates Netz/);
});
it("GIT_ALLOWED_HOSTS laesst ausschliesslich die genannten Hosts zu", async () => {
process.env.GIT_ALLOWED_HOSTS = "github.com";
await expect(assertAllowedUrl("https://github.com/x/y")).resolves.toBeInstanceOf(URL);
expect(await blockedReason("https://gitlab.com/x/y")).toMatch(/GIT_ALLOWED_HOSTS/);
});
it("eine ausdrueckliche Freigabe schlaegt die Bereichspruefung", async () => {
// Damit laesst sich eine Gitea auf demselben Rechner gezielt erlauben.
process.env.GIT_ALLOWED_HOSTS = "127.0.0.1";
await expect(assertAllowedUrl("http://127.0.0.1:3000")).resolves.toBeInstanceOf(URL);
});
});
+12
View File
@@ -1,6 +1,7 @@
import type { Project } from "@prisma/client"; import type { Project } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import { notifyAfterSync } from "@/lib/notify";
import type { RepoSnapshotData } from "@/lib/git/types"; import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult { export interface SyncResult {
@@ -54,6 +55,8 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
try { try {
const data = await client.fetchRepo(ref); const data = await client.fetchRepo(ref);
await persistSnapshot(project.id, data); await persistSnapshot(project.id, data);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(project.id);
return { return {
ok: true, ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
@@ -70,6 +73,7 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
syncFailureCount: { increment: 1 }, syncFailureCount: { increment: 1 },
}, },
}); });
await notifyAfterSync(project.id);
return { ok: false, message }; return { ok: false, message };
} }
} }
@@ -117,6 +121,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
commits: data.commits as unknown as object, commits: data.commits as unknown as object,
releases: data.releases as unknown as object, releases: data.releases as unknown as object,
languages: data.languages as unknown as object, languages: data.languages as unknown as object,
pullRequests: data.pullRequests as unknown as object,
issues: data.issues as unknown as object,
commitActivity: data.commitActivity as unknown as object,
ciState: data.ci?.state ?? null,
ciName: data.ci?.name ?? null,
ciBranch: data.ci?.branch ?? null,
ciUrl: data.ci?.url ?? null,
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null, pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
fetchedAt: new Date(), fetchedAt: new Date(),
}; };
+42 -3
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { getAppSettings } from "@/lib/auth/settings"; import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { runStaleCheck } from "@/lib/notify/stale";
import { syncProject } from "@/lib/sync"; import { syncProject } from "@/lib/sync";
/** /**
@@ -20,6 +21,8 @@ import { syncProject } from "@/lib/sync";
*/ */
const TICK_MS = 60_000; const TICK_MS = 60_000;
/** Stillstand aendert sich langsam – einmal am Tag genuegt. */
const STALE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Kleine Verschnaufpause zwischen zwei Projekten. */ /** Kleine Verschnaufpause zwischen zwei Projekten. */
const PAUSE_MS = 400; const PAUSE_MS = 400;
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */ /** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
@@ -64,18 +67,54 @@ export function startAutoSyncScheduler() {
async function tick() { async function tick() {
try { try {
const settings = await getAppSettings(); const settings = await getAppSettings();
if (!settings.autoSyncEnabled) return;
if (settings.autoSyncEnabled) {
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const last = settings.lastAutoSyncAt?.getTime() ?? 0; const last = settings.lastAutoSyncAt?.getTime() ?? 0;
if (Date.now() - last < intervalMs) return; if (Date.now() - last >= intervalMs) {
await runAutoSyncPass("zeitplan"); await runAutoSyncPass("zeitplan");
}
}
// Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem
// gespeicherten Stand, dafuer muss nichts geholt werden.
await maybeCheckStale();
} catch (error) { } catch (error) {
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
} }
} }
/**
* Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt
* jemand darauf hoert – sonst waere es reine Datenbanklast.
*/
async function maybeCheckStale() {
const settings = await getAppSettings();
const last = settings.lastStaleCheckAt?.getTime() ?? 0;
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
const zuhoerer = await prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
});
// Zeitstempel auch ohne Zuhoerer setzen, damit nicht jede Minute gezaehlt
// wird.
await prisma.appSettings.update({
where: { id: "singleton" },
data: { lastStaleCheckAt: new Date() },
});
if (zuhoerer === 0) return;
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
if (report.stale > 0) {
console.log(
`[nebula] Stillstand: ${report.stale} von ${report.checked} Projekten ` +
`ueber ${report.thresholdDays} Tage ohne Commit, ${report.notified} Meldungen.`,
);
}
}
/** /**
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf * Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
* waehrend eines laufenden Durchlaufs bekommt null zurueck. * waehrend eines laufenden Durchlaufs bekommt null zurueck.
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks";
describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => {
expect(progressFromTasks(4, 1)).toBe(25);
expect(progressFromTasks(8, 3)).toBe(38);
expect(progressFromTasks(3, 3)).toBe(100);
expect(progressFromTasks(5, 0)).toBe(0);
});
it("gibt null zurueck, wenn es nichts zu rechnen gibt", () => {
// Ohne Aufgaben bleibt der von Hand gesetzte Wert stehen.
expect(progressFromTasks(0, 0)).toBeNull();
expect(progressFromTasks(-1, 0)).toBeNull();
});
it("rundet kaufmaennisch statt abzuschneiden", () => {
// 1/3 sind 33,33 % und 2/3 sind 66,67 %.
expect(progressFromTasks(3, 1)).toBe(33);
expect(progressFromTasks(3, 2)).toBe(67);
});
});
describe("Spaltenreihenfolge", () => {
it("fuehrt von offen nach erledigt", () => {
expect(TASK_STATUS_ORDER).toEqual(["TODO", "DOING", "BLOCKED", "DONE"]);
});
it("hat zu jeder Spalte eine Beschriftung und eine Farbe", () => {
for (const status of TASK_STATUS_ORDER) {
expect(TASK_STATUS_META[status].label).toBeTruthy();
expect(TASK_STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */
export const TASK_STATUS_ORDER: TaskStatus[] = ["TODO", "DOING", "BLOCKED", "DONE"];
export const TASK_STATUS_META: Record<
TaskStatus,
{ label: string; color: string; className: string }
> = {
TODO: {
label: "Offen",
color: "#94a3b8",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
},
DOING: {
label: "In Arbeit",
color: "#a78bfa",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
BLOCKED: {
label: "Blockiert",
color: "#fb7185",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
},
DONE: {
label: "Erledigt",
color: "#34d399",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
/**
* Berechnet den Fortschritt aus dem Verhaeltnis erledigter Aufgaben.
* Ohne Aufgaben gibt es nichts zu berechnen – dann bleibt der Wert, wie er ist.
*/
export function progressFromTasks(total: number, done: number): number | null {
if (total <= 0) return null;
return Math.round((done / total) * 100);
}
/**
* Schreibt den abgeleiteten Fortschritt, falls das Projekt es so eingestellt
* hat. Wird nach jeder Aenderung an Aufgaben aufgerufen.
*/
export async function refreshDerivedProgress(projectId: string): Promise<void> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { progressFromTasks: true },
});
if (!project?.progressFromTasks) return;
const [total, done] = await Promise.all([
prisma.task.count({ where: { projectId } }),
prisma.task.count({ where: { projectId, status: "DONE" } }),
]);
const value = progressFromTasks(total, done);
if (value === null) return;
await prisma.project.update({
where: { id: projectId },
data: { progress: value },
});
}
/** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition(
projectId: string,
status: TaskStatus,
): Promise<number> {
const last = await prisma.task.findFirst({
where: { projectId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/** Prueft Existenz und Besitz in einem Zug. */
export async function ownedProject(projectId: string, ownerId: string) {
return prisma.project.findFirst({
where: { id: projectId, ownerId },
select: { id: true },
});
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { formatNumber, parseTags, slugify, truncate } from "./utils";
describe("slugify", () => {
it("macht aus einem Namen einen URL-tauglichen Slug", () => {
expect(slugify("Mein Projekt")).toBe("mein-projekt");
});
it("schreibt deutsche Umlaute aus", () => {
expect(slugify("Grün & Größe")).toBe("gruen-groesse");
expect(slugify("Straße")).toBe("strasse");
});
it("entfernt fuehrende und abschliessende Trennzeichen", () => {
expect(slugify(" ...Hallo Welt!!! ")).toBe("hallo-welt");
});
it("faellt bei reinen Sonderzeichen auf einen leeren Slug zurueck", () => {
// Die Aufrufer ersetzen das durch "projekt".
expect(slugify("!!!")).toBe("");
});
it("begrenzt die Laenge", () => {
expect(slugify("a".repeat(200)).length).toBeLessThanOrEqual(60);
});
});
describe("parseTags", () => {
it("trennt an Kommas, trimmt und schreibt klein", () => {
expect(parseTags(" Next.js , PRISMA ,dashboard ")).toEqual([
"next.js",
"prisma",
"dashboard",
]);
});
it("entfernt Dubletten und Leereintraege", () => {
expect(parseTags("a,,a, b ,A")).toEqual(["a", "b"]);
});
it("begrenzt auf zwoelf Tags", () => {
const many = Array.from({ length: 20 }, (_, index) => `tag${index}`).join(",");
expect(parseTags(many)).toHaveLength(12);
});
});
describe("truncate", () => {
it("laesst kurze Texte unveraendert", () => {
expect(truncate("kurz", 10)).toBe("kurz");
});
it("kuerzt und haengt ein Auslassungszeichen an", () => {
const result = truncate("abcdefghij", 5);
expect(result).toHaveLength(5);
expect(result.endsWith("…")).toBe(true);
});
});
describe("formatNumber", () => {
it("gruppiert nach deutscher Schreibweise", () => {
// Das Trennzeichen kann je nach ICU-Version variieren.
expect(formatNumber(32437)).toMatch(/^32.437$/);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { notificationTargetCreateSchema } from "./validation";
/**
* Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen
* Anlasses war das Zod-Schema nicht mitgezogen worden, die API lehnte ihn
* mit 422 ab. Die Liste steht an zwei Stellen – hier wird geprueft, dass
* sie zusammenpassen.
*/
const basis = {
name: "Handy",
channel: "NTFY" as const,
target: "https://ntfy.sh/mein-thema",
};
describe("notificationTargetCreateSchema", () => {
it("kennt jeden Anlass, den die Oberflaeche anbietet", () => {
for (const event of NOTIFICATION_EVENTS) {
const ergebnis = notificationTargetCreateSchema.safeParse({
...basis,
events: [event],
});
expect(ergebnis.success, `Anlass ${event} wird abgelehnt`).toBe(true);
}
});
it("verlangt mindestens einen Anlass", () => {
expect(notificationTargetCreateSchema.safeParse({ ...basis, events: [] }).success).toBe(
false,
);
});
it("weist unbekannte Anlaesse ab", () => {
expect(
notificationTargetCreateSchema.safeParse({ ...basis, events: ["WAS_AUCH_IMMER"] })
.success,
).toBe(false);
});
it("verlangt bei E-Mail eine Adresse, keine URL", () => {
const alsUrl = notificationTargetCreateSchema.safeParse({
name: "Post",
channel: "EMAIL",
target: "https://example.com",
events: ["SYNC_FAILED"],
});
expect(alsUrl.success).toBe(false);
const alsAdresse = notificationTargetCreateSchema.safeParse({
name: "Post",
channel: "EMAIL",
target: "ich@example.com",
events: ["SYNC_FAILED"],
});
expect(alsAdresse.success).toBe(true);
});
it("verlangt bei Webhook und ntfy eine http-URL", () => {
for (const channel of ["WEBHOOK", "NTFY"] as const) {
expect(
notificationTargetCreateSchema.safeParse({
name: "X",
channel,
target: "ich@example.com",
events: ["SYNC_FAILED"],
}).success,
).toBe(false);
}
});
});
+73
View File
@@ -23,10 +23,47 @@ export const projectCreateSchema = z.object({
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
/** Am automatischen Abgleich teilnehmen. */ /** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true), autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
progressFromTasks: z.boolean().default(false),
}); });
export const projectUpdateSchema = projectCreateSchema.partial(); export const projectUpdateSchema = projectCreateSchema.partial();
// ── Aufgaben ─────────────────────────────────────────────────
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const dueDateField = z.preprocess(
emptyToNull,
z
.string()
.trim()
.refine((value) => !Number.isNaN(Date.parse(value)), "Kein gueltiges Datum")
.nullable()
.optional(),
);
export const taskCreateSchema = z.object({
title: z.string().trim().min(1, "Titel darf nicht leer sein").max(200),
description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()),
status: z.enum(TASK_STATUS).default("TODO"),
dueDate: dueDateField,
labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]),
});
export const taskUpdateSchema = taskCreateSchema.partial();
/**
* 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.
*/
export const taskReorderSchema = z.object({
columns: z
.record(z.enum(TASK_STATUS), z.array(z.string().min(1)).max(500))
.refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"),
});
export const noteCreateSchema = z.object({ export const noteCreateSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000), content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
@@ -109,6 +146,8 @@ export const profileUpdateSchema = z.object({
export const adminSettingsSchema = z.object({ export const adminSettingsSchema = z.object({
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(), mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
allowRegistration: z.boolean().optional(), allowRegistration: z.boolean().optional(),
/** Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. */
staleAfterDays: z.coerce.number().int().min(7).max(365).optional(),
autoSyncEnabled: z.boolean().optional(), autoSyncEnabled: z.boolean().optional(),
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */ /** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(), autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
@@ -133,6 +172,40 @@ export const adminUserUpdateSchema = z.object({
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()), password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
}); });
// ── Benachrichtigungen ───────────────────────────────────────
const CHANNELS = ["WEBHOOK", "NTFY", "EMAIL"] as const;
const EVENTS = ["SYNC_FAILED", "CI_FAILED", "NEW_RELEASE", "PROJECT_STALE"] as const;
export const notificationTargetCreateSchema = z
.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(60),
channel: z.enum(CHANNELS),
target: z.string().trim().min(1, "Ziel fehlt").max(500),
/** Bearer-Token fuer ntfy oder Signaturschluessel fuer den Webhook. */
secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()),
events: z.array(z.enum(EVENTS)).min(1, "Mindestens ein Anlass"),
isActive: z.boolean().default(true),
})
.refine(
(value) =>
value.channel === "EMAIL"
? /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.target)
: /^https?:\/\//i.test(value.target),
{
message: "E-Mail-Adresse bzw. URL mit http:// oder https:// erwartet",
path: ["target"],
},
);
export const notificationTargetUpdateSchema = z.object({
name: z.string().trim().min(1).max(60).optional(),
target: z.string().trim().min(1).max(500).optional(),
secret: z.preprocess(emptyToNull, z.string().trim().max(300).nullable().optional()),
events: z.array(z.enum(EVENTS)).min(1).optional(),
isActive: z.boolean().optional(),
});
export const passkeyNameSchema = z.object({ export const passkeyNameSchema = z.object({
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()), name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
}); });
+35 -5
View File
@@ -1,5 +1,20 @@
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client"; import type {
import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types"; GitProvider,
Note,
Project,
ProjectStatus,
RepoSnapshot,
Task,
TaskStatus,
} from "@prisma/client";
import type {
BranchInfo,
CiInfo,
CommitInfo,
ReleaseInfo,
TagInfo,
TicketInfo,
} from "@/lib/git/types";
/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */ /** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */
export interface ConnectionSafe { export interface ConnectionSafe {
@@ -23,14 +38,17 @@ export interface ConnectionWithCount extends ConnectionSafe {
export interface ProjectListItem extends Project { export interface ProjectListItem extends Project {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null; snapshot: RepoSnapshot | null;
_count: { notes: number }; _count: { notes: number; tasks: number };
/** Nur die erledigten – siehe projectListInclude. */
tasks: { id: string }[];
} }
export interface ProjectDetail extends Project { export interface ProjectDetail extends Project {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null; snapshot: RepoSnapshot | null;
notes: Note[]; notes: Note[];
_count: { notes: number }; tasks: Task[];
_count: { notes: number; tasks: number };
} }
export interface RepoListEntry { export interface RepoListEntry {
@@ -44,7 +62,19 @@ export interface RepoListEntry {
updatedAt: string | null; updatedAt: string | null;
} }
export type { BranchInfo, CommitInfo, Note, ProjectStatus, ReleaseInfo, RepoSnapshot, TagInfo }; export type {
BranchInfo,
CiInfo,
CommitInfo,
Note,
ProjectStatus,
ReleaseInfo,
RepoSnapshot,
TagInfo,
Task,
TaskStatus,
TicketInfo,
};
/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */ /** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */
export function asArray<T>(value: unknown): T[] { export function asArray<T>(value: unknown): T[] {
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
test: {
// Reine Logik, kein DOM und keine Datenbank – die Tests laufen in
// Millisekunden und brauchen weder Server noch Container.
environment: "node",
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});