49 Commits
Author SHA1 Message Date
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
florian.wildeandClaude Opus 5 04e8437f29 feat(ui): Markdown in Beschreibung und Notizen
Bisher wurde alles als reiner Text ausgegeben. Jetzt werden Ueberschriften,
Listen, Tabellen, Codebloecke, Zitate und Links dargestellt, und aus
"- [ ] Aufgabe" werden echte Checklisten.

Sicherheitsrelevant: der Inhalt stammt vom Benutzer. Ohne rehype-sanitize
waere das ein XSS-Loch, sobald jemand rohes HTML in eine Notiz schreibt. Das
Standardschema erlaubt nur harmlose Elemente; ergaenzt wird nur, was fuer
Aufgabenlisten und Sprachklassen an Codebloecken noetig ist. Links bekommen
zusaetzlich target=_blank mit noopener und nofollow.

Die Formatierung steht in components.css: Tailwinds Preflight setzt alle
Abstaende und Schriftgroessen zurueck, Ueberschriften und Listen brauchen
also wieder Form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:03:55 +02:00
florian.wildeandClaude Opus 5 67d9222c97 docs: Export, Import und Sicherung dokumentieren
README bekommt einen eigenen Abschnitt mit den vier Wegen, den drei
Importstrategien und beiden Sicherungsarten - samt der Empfehlung, eine
Sicherung einmal in eine Wegwerf-Datenbank zurueckzuspielen. FEATURES
beschreibt dasselbe ausfuehrlicher.

In der ROADMAP wandert Export und Import zu den erledigten Punkten, mit den
getroffenen Entscheidungen: keine Tokens, keine Snapshots, Voll-Sicherung
nur fuer Administratoren. Die folgenden Punkte ruecken auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 7a98c8852e feat(ops): Sicherungsskripte mit pg_dump fuer Windows und Unix
Die JSON-Sicherung im Adminbereich eignet sich zum Umziehen. Zum
Wiederherstellen einer kaputten Instanz braucht es dagegen einen echten
Dump - Schema, Daten, Sequenzen, Indizes.

backup.ps1 und backup.sh fuehren pg_dump im Container aus, es muss also
nichts auf dem Rechner installiert sein. Benutzer und Datenbankname kommen
aus der .env, damit sie nicht doppelt gepflegt werden. Der Dump entsteht mit
--clean --if-exists und ist damit fuer sich allein einspielbar.

restore.* fragt vor dem Ueberschreiben nach und laesst sich ueber -Database
beziehungsweise NEBULA_DB_NAME auf eine andere Datenbank umlenken. Damit
laesst sich eine Sicherung pruefen, ohne die echte anzufassen - eine
Sicherung, die nie zurueckgespielt wurde, ist keine.

/backups liegt im .gitignore: die Dateien enthalten alle Daten der Instanz.

Geprueft am echten Bestand: Dump 30,9 KB mit allen neun Tabellen,
Rueckspielung in eine Pruefdatenbank ergab dieselben Zahlen, das Original
blieb unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 a17f32c378 feat(ui): Export und Import in der Oberflaeche
Dashboard: ein Datenmenue neben "Neues Projekt" mit Export aller Projekte
und Import. Projektseite: Ausklappmenue mit JSON und Markdown.
Adminbereich: Abschnitt "Sicherung" mit deutlichem Hinweis, was in der Datei
steht und wie vertraulich sie zu behandeln ist.

Der Import-Dialog fuehrt in zwei Schritten: Datei waehlen, Vorschau ansehen,
erst dann schreiben. Ein Wechsel der Strategie berechnet die Vorschau neu.
Der Ausfuehren-Knopf bleibt gesperrt, solange sich nichts aendern wuerde -
sonst waere er ein Angebot, das ins Leere laeuft. Fehlende Git-Verbindungen
werden gesondert hervorgehoben, mit dem Hinweis, dass Tokens nie in
Exportdateien stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:57 +02:00
florian.wildeandClaude Opus 5 91c94e7383 feat(export): Projekte exportieren und importieren
Bislang lagen alle Daten ausschliesslich in der Datenbank - ein
"docker compose down -v" haette alles geloescht, ohne Rueckweg.

Export:
- alle eigenen Projekte oder ein einzelnes als JSON
- ein Projekt zusaetzlich als lesbares Markdown, inklusive letztem
  bekannten Git-Stand

Was bewusst nicht in der Datei steht:
- Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
  Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
  Exportdatei landet sonst schnell in einem Download-Ordner oder einem
  Cloud-Abgleich.
- Repo-Snapshots. Reproduzierbar und gross; ein Abgleich holt sie zurueck.
- Datenbank-IDs und der Besitzer. Beides gilt nur in der Quellinstanz.

Import:
- die hochgeladene Datei ist Fremdinhalt und wird vor jeder Verwendung
  streng gegen ein Zod-Schema geprueft
- Abgleich ueber den Slug, drei Strategien: ueberspringen, zusaetzlich
  anlegen, ersetzen
- dryRun liefert dieselbe Auswertung, ohne zu schreiben - Grundlage der
  Vorschau in der Oberflaeche
- Verbindungen werden ueber Name oder Provider samt Basis-URL zugeordnet;
  fehlt eine, wird das Projekt ohne Verknuepfung angelegt und das gemeldet
- beim Ersetzen werden Notizen ausgetauscht statt zusammengefuehrt: das ist
  vorhersagbar, und ein erneuter Import erzeugt keine Dubletten

Die Instanz-Sicherung fuer Administratoren enthaelt dagegen alles, was zum
Wiederherstellen noetig ist - Passwort-Hashes und Token-Chiffrate
eingeschlossen. Ohne dasselbe APP_SECRET sind die Tokens wertlos. Sitzungen
und Passkeys bleiben draussen: erstere sind kurzlebig, letztere haengen an
Geraet und rpID.

readJsonBody bekommt eine einstellbare Obergrenze - 256 KB reichen fuer eine
Exportdatei mit vielen Notizen nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:33:26 +02:00
florian.wildeandClaude Opus 5 bad90753d0 docs: Abgleich dokumentieren und Roadmap anlegen
README und FEATURES beschreiben den automatischen Abgleich samt
Zurueckhalten nach Fehlschlaegen und dem Weg ueber einen externen Zeitgeber.

Neu ist ROADMAP.md: die bisherige Liste "Ideen fuer spaeter" war eine lose
Aufzaehlung ohne Reihenfolge und ohne Aufwandsabschaetzung. Jetzt nach
Prioritaet gegliedert, jeder Punkt mit Aufwand und - wo es welche gibt -
offenen Fragen. Ganz oben steht Export und Import in vier Stufen, inklusive
der Frage, ob Git-Tokens mitgehen sollen (Vorschlag: nein).

Am Ende steht bewusst auch, was nicht kommt und warum - das erspart
spaetere Diskussionen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
florian.wildeandClaude Opus 5 4f2c221db6 feat(admin): Abgleich einstellbar und Schalter je Projekt
Adminbereich, Abschnitt "Automatischer Abgleich":
- ein- und ausschalten
- Abstand von 15 Minuten bis taeglich
- Projekte je Durchlauf
- fertige und archivierte auslassen
- Zustand auf einen Blick: verknuepfte Projekte, letzter und naechster
  Durchlauf, Kurzbericht des letzten Laufs
- Knopf "Jetzt abgleichen" fuer einen sofortigen Durchlauf

Der Endpunkt POST /api/admin/sync/run dient zugleich als Einstieg fuer einen
externen Zeitgeber, falls jemand lieber von aussen taktet als den eingebauten
Automatismus zu nutzen.

Im Projektdialog laesst sich ein einzelnes Projekt vom Abgleich ausnehmen -
nuetzlich bei einem Repo, dessen Token staendig Fehler wirft. Manuell
synchronisieren geht unabhaengig davon immer.

Die Zahl der in Frage kommenden Projekte wird ueber alle Konten gezaehlt: der
Abgleich ist ein Serverauftrag, keine Dateneinsicht. Ein Administrator sieht
dadurch weiterhin keine fremden Projekte.

Nachgewiesen: Durchlauf 1 aktualisiert das echte Repo und meldet das kaputte
als Fehlschlag, Durchlauf 2 stellt das kaputte zurueck, das ausgenommene
Projekt bleibt in beiden unberuehrt. Der Zeitplan loeste eigenstaendig aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
florian.wildeandClaude Opus 5 ed2637877e feat(sync): automatischer Abgleich nach Zeitplan
Bisher musste jedes Projekt einzeln von Hand synchronisiert werden. Jetzt
gibt es einen Zeitgeber im Serverprozess, angestossen von
instrumentation.ts. Fuer eine Einzelinstanz ist das die einfachste
tragfaehige Loesung: kein zusaetzlicher Container, kein externer Cron, keine
zweite Konfiguration.

Ruecksicht auf fremde API-Kontingente war der bestimmende Gedanke:

- standardmaessig abgeschaltet - der Abgleich verbraucht fremdes Kontingent,
  das soll eine bewusste Entscheidung sein
- je Durchlauf nur eine begrenzte Zahl Projekte, die am laengsten nicht
  abgeglichenen zuerst; der Rest kommt beim naechsten Mal
- nach jedem Fehlschlag verdoppelt sich der Abstand bis zum naechsten
  Versuch, gedeckelt beim Sechzehnfachen. Ein Repo mit abgelaufenem Token
  blockiert damit nicht dauerhaft das Kontingent.
- kurze Pause zwischen zwei Projekten
- zwei Durchlaeufe ueberschneiden sich nie
- fertige und archivierte Projekte lassen sich auslassen
- einzelne Projekte lassen sich ausnehmen

Der Zaehler fuer Fehlschlaege wandert in syncProject: bei Erfolg zurueck auf
null, bei Fehlschlag hoch. So stimmt er auch nach einem manuellen Abgleich.

sync.ts wird zu sync/index.ts, damit Modul und Ordner nicht nebeneinander
stehen. Alle Importpfade bleiben unveraendert.

Zur Form der Abfrage in instrumentation.ts: Next uebersetzt die Datei auch
fuer den Edge-Runtime. Der Import muss deshalb *in* der positiven Bedingung
stehen, sonst bleibt er im Edge-Bundle stehen und der Build scheitert an
node:crypto. Mit einem frueh abbrechenden return passiert genau das.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:37:54 +02:00
florian.wildeandClaude Opus 5 b865cf49bd fix(ui): Anzeigefehler aus der Browser-Pruefung beheben
Erstmals mit echtem Browser nachgesehen (headless Chrome ueber Playwright,
Screenshots aller Ansichten). Dabei fuenf Dinge gefunden:

1. Menue klebte am Fensterrand statt am Ausloeser. Bei einem Ausloeser weit
   links wurde die bevorzugte rechtsbuendige Ausrichtung negativ und dann auf
   den Rand geklemmt - das Menue stand quer ueber der Seite. Jetzt wird bei
   Ueberlauf auf die andere Ausrichtung gewechselt und erst danach geklemmt.

2. .popover war mit 97 % nicht ganz deckend, wodurch "Bearbeiten" und
   "Loeschen" schwach durchschienen. Jetzt vollstaendig deckend.

3. Der Schieberegler zeichnete die Spur rechts vom Griff in einem hellen
   Standardton - ein grelles Band im dunklen Layout. Ersetzt durch eine
   selbst gezeichnete Variante mit Farbverlauf und sichtbarem Griff. Auf der
   Projektseite ersetzt sie zugleich das bisherige unsichtbare Eingabefeld
   ueber einem gezeichneten Balken, das gar keinen Griff hatte.

4. Der Hinweis der Kachel "Ideen" wurde mitten im Wort abgeschnitten
   ("Noch ein Gedanke - nichts ge..."). Kurzer Text statt der langen
   Statusbeschreibung.

5. Nach der Einrichtung stand im Adminbereich "noch nie angemeldet", obwohl
   gerade eine Sitzung lief. Einrichtung und Registrierung setzen lastLoginAt
   jetzt mit.

Die Konsole blieb ueber alle Ansichten hinweg frei von Fehlern und Warnungen
- die strikte CSP mit Nonce und strict-dynamic bricht also nichts.

Geprueft an einer separaten Wegwerf-Datenbank auf Port 3100, damit die
laufende Instanz unberuehrt blieb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:46:00 +02:00
florian.wildeandClaude Opus 5 9a4cc75115 fix(ui): Menues und Dialoge ueber Portal ausgeben
Das Statusmenue auf der Projektseite wurde von den Schaltflaechen
"Bearbeiten" und "Loeschen" ueberdeckt, war unten abgeschnitten und der
Fortschrittswert schien hindurch. Drei Ursachen, alle mit derselben Wurzel:

1. Die Karten setzen overflow-hidden fuer den Akzentstreifen und den
   Farbverlaufsrand. Ein absolut positioniertes Kind wird daran
   abgeschnitten.
2. .glass setzt backdrop-filter, die Projektkarte bekommt beim Hovern
   zusaetzlich ein transform. Beides erzeugt einen Stacking-Context: das
   z-index des Menues galt nur noch innerhalb der Karte und verlor gegen
   spaeter gezeichnete Geschwister. Aus demselben Grund bezog sich das
   "position: fixed" der Klickflaeche zum Schliessen auf die Karte statt
   auf das Fenster - ein Klick daneben schloss das Menue nicht.
3. .glass hat 3,5 % Deckkraft. Als Kartenflaeche gut, als Overlay
   unbrauchbar. Der eigene backdrop-filter half nicht, weil er innerhalb
   eines Vorfahren mit backdrop-filter wirkungslos bleibt.

Behoben durch eine neue Popover-Komponente, die per createPortal am body
haengt, ihre Position aus dem Ankerelement berechnet und beim Scrollen und
bei Groessenaenderungen nachfuehrt. Reicht der Platz nach unten nicht, klappt
sie nach oben. Dazu die Klasse .popover mit nahezu deckendem Hintergrund.

Betrifft Statusauswahl, Benutzermenue und - aus demselben Grund - den
Modal-Dialog: der wird aus Karten heraus geoeffnet und haette sich sonst an
der Karte statt am Fenster ausgerichtet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:11 +02:00
florian.wildeandClaude Opus 5 ec7cb02a9a docs: Benutzerverwaltung, Passkeys und Sicherheitsmassnahmen dokumentieren
README: Abschnitt zur ersten Anmeldung mit den beiden Betriebsarten und der
Einrichtung eines Passkeys, erweiterte Variablentabelle mit dem Hinweis, dass
Name und URL zur Laufzeit gelesen werden, vollstaendige API-Uebersicht sowie
ein Sicherheitsabschnitt, der auch die Grenzen benennt: kein HSTS, keine
TLS-Terminierung, Ratenbegrenzung nur im Arbeitsspeicher, kein
Passwort-Zuruecksetzen per E-Mail, und warum Anfragen ohne Origin- und
Sec-Fetch-Site-Header durchgelassen werden.

FEATURES: neues Kapitel zu Konten und Zugang, aktualisiertes Datenmodell und
ausgebauter Sicherheitsabschnitt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 9a9b413d3e chore: neue Umgebungsvariablen in .env.example und Compose
APP_NAME, APP_TAGLINE und APP_URL sowie SESSION_TTL_DAYS und die drei
Schalter des SSRF-Schutzes werden an den App-Container durchgereicht.

Bei APP_URL steht ausdruecklich dabei, dass daraus die WebAuthn-rpID
abgeleitet wird, dass Passkeys einen sicheren Kontext brauchen und dass ein
spaeterer Hostnamenwechsel bestehende Passkeys ungueltig macht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 ed77ffaed2 feat(security): alle Daten auf den Besitzer einschraenken
Bisher sah jede Anfrage alles. Jetzt traegt jede Abfrage eine
Besitzerbedingung, und zwar an erster Stelle in der where-Klausel statt als
nachtraeglicher Vergleich - so kann sie nicht versehentlich uebersprungen
werden.

- Lesen: findFirst statt findUnique, immer mit ownerId
- Schreiben und Loeschen: updateMany/deleteMany mit ownerId in der
  where-Klausel. Fremde Datensaetze werden dadurch gar nicht erst getroffen,
  und die betroffene Zeilenzahl sagt, ob es den Datensatz gab.
- Notizen haengen ueber project.ownerId am Besitzer
- Beim Verknuepfen eines Projekts wird geprueft, dass die Git-Verbindung dem
  Aufrufer gehoert - sonst liesse sich eine fremde Verbindung und damit ein
  fremder Token unterschieben
- Fremde IDs liefern 404 statt 403: die Antwort bestaetigt nicht einmal,
  dass es den Datensatz gibt
- Ausgehende Endpunkte (Sync, Verbindungstest, Repo-Liste) zusaetzlich
  ratenbegrenzt, Basis-URLs gegen den SSRF-Schutz geprueft
- Slug und Verbindungsname werden je Besitzer auf Eindeutigkeit geprueft

Nachgewiesen: ein zweites Konto erhaelt auf GET, PATCH, DELETE, Notiz
anlegen und Sync eines fremden Projekts durchgaengig 404, auf die
Admin-Endpunkte 403, und das Zielprojekt bleibt unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
florian.wildeandClaude Opus 5 cf4103a46c feat(admin): Kontoverwaltung und Admin-Einstellungen
"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen,
umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden.
Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort
gesetzt ist - sonst waere das Konto ausgesperrt.

Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben,
Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus
der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls
nicht verfuegbar sind.

Schutzregeln:
- Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein
  Konto existiert.
- Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert
  oder geloescht werden.
- Das eigene Konto laesst sich nicht loeschen oder deaktivieren.
- Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des
  Kontos.

Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch
sie nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
florian.wildeandClaude Opus 5 4f85e00016 feat(auth): Einrichtung, Anmeldeseite und Registrierung
- /setup fuehrt beim ersten Aufruf durch das Anlegen des Administratorkontos
  und die Wahl der Betriebsart. Der Endpunkt funktioniert nur, solange es
  keinen Benutzer gibt; danach kann sich niemand mehr nachtraeglich ein
  Adminkonto erschleichen. Vorhandene Projekte ohne Besitzer werden dabei
  uebernommen und im Formular vorher angekuendigt.
- /login mit Passwort oder Passkey. Der Passkey-Knopf entfaellt, wenn APP_URL
  keinen sicheren Kontext ergibt - mit Hinweis, woran es liegt. Das Ziel nach
  der Anmeldung wird auf relative Pfade begrenzt, sonst waere der
  weiter-Parameter ein offener Redirect.
- /register nur im Mehrbenutzerbetrieb und nur, wenn ein Administrator die
  Selbstregistrierung freigegeben hat.
- Kopfzeile mit Benutzermenue: eigenes Konto, Admin-Einstellungen, Abmelden.
  Ohne Anmeldung bleibt sie auf das Logo reduziert.
- Layout liest Titel und Namen ueber generateMetadata, damit APP_NAME zur
  Laufzeit greift statt beim Build eingefroren zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
florian.wildeandClaude Opus 5 13bc5aa0b8 feat(auth): Anmeldung mit Passwort und Passkey
Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
florian.wildeandClaude Opus 5 01d42662ca feat(db): Datenmodell fuer Benutzer, Sitzungen und Passkeys
Neue Tabellen:
- User        Konto mit Rolle, optionalem Passwort-Hash, Sperrzaehler.
              Das Passwort ist optional, weil ein Konto auch ausschliesslich
              per Passkey nutzbar sein darf.
- Session     serverseitige Sitzung. Gespeichert wird nur der SHA-256-Hash
              des Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren
              Sitzungen ergibt.
- Credential  ein Passkey je Zeile, mit oeffentlichem Schluessel, Transporten
              und Signaturzaehler.
- WebAuthnChallenge  kurzlebig und einmalig verwendbar, deshalb in der
              Datenbank statt im Cookie.
- AppSettings  Betriebsart und Registrierung, genau eine Zeile.

Project und GitConnection bekommen einen Besitzer. Bewusst nullable: so
ueberstehen Bestandsdaten die Migration und werden beim Anlegen des ersten
Kontos uebernommen. Slug und Verbindungsname sind dadurch nur noch je
Besitzer eindeutig - zwei Personen duerfen beide ein Projekt "dashboard"
haben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
florian.wildeandClaude Opus 5 dbcf30efc1 feat(security): SSRF-Schutz, Ratenbegrenzung und harte Body-Pruefung
Die Basis-URL einer Git-Verbindung ist eine Benutzereingabe, die der Server
anschliessend selbst abruft. Ohne Pruefung liesse sich darueber jede vom
Server erreichbare Adresse ansprechen - Cloud-Metadaten unter
169.254.169.254, lokal gebundene Dienste oder ein Portscan im Heimnetz.

ssrf.ts prueft deshalb vor jedem ausgehenden Aufruf:
- Loopback, Link-Local samt Metadaten-Endpunkt, unspezifizierte Adressen,
  Multicast und CGNAT sind gesperrt
- das private LAN bleibt erlaubt, weil dort ueblicherweise die eigene
  Gitea-Instanz steht; abschaltbar ueber GIT_BLOCK_PRIVATE
- GIT_ALLOW_LOOPBACK fuer eine Instanz auf demselben Rechner,
  GIT_ALLOWED_HOSTS als strengster Modus
- Hostnamen werden aufgeloest und *jede* zurueckgegebene Adresse geprueft,
  sonst genuegte ein zweiter A-Record fuer DNS-Rebinding
- nach einem Redirect wird das tatsaechliche Ziel erneut geprueft

Dazu:
- rateLimit.ts, ein Zaehler im Arbeitsspeicher mit vorkonfigurierten
  Kontingenten fuer Anmeldung, ausgehende Aufrufe und WebAuthn
- request.ts liest JSON-Bodies nur mit Content-Type application/json und
  hoechstens 256 KB. Die Content-Type-Pflicht ist zugleich CSRF-Schutz in
  der Tiefe: ein Cross-Site-Formular kommt ohne Preflight nur mit
  text/plain, multipart oder urlencoded durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
florian.wildeandClaude Opus 5 950bca1b81 fix(ui): Komponentenklassen in @layer components verschieben
Im Suchfeld lag das Lupensymbol auf dem Platzhaltertext. Ursache war die
Kaskade: .field steht mit der Kurzschreibweise "padding: .6rem .85rem" im
Stylesheet hinter Tailwinds Utilities, also gewann sie gegen "pl-9" und das
linke Padding blieb bei 13,6 Pixeln - zu wenig fuer das Symbol.

Die wiederkehrenden Bausteine liegen jetzt in einer eigenen Datei und dort in
@layer components. Tailwind sortiert die Layer als theme, base, components,
utilities; damit gewinnen Utilities wieder gegen die Komponentenklasse.

Das behebt nicht nur die Ueberlappung: auch alle Schaltflaechen mit
"btn ... px-2.5 py-1.5" wurden bisher in der Standardgroesse gerendert, weil
.btn ebenfalls eine padding-Kurzschreibweise setzt. Sie sind jetzt so
kompakt wie vorgesehen.

Geprueft am gebauten Stylesheet: .field liegt an Position 11107 im
components-Layer, .pl-9 an 31324 - die Utility gewinnt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
florian.wildeandClaude Opus 5 8d26a6d08c docs: getestete Versionen und Hinweis zur Prisma-Warnung ergaenzen
Haelt fest, womit beide Betriebsarten tatsaechlich verifiziert wurden, und
erklaert die Deprecation-Warnung von Prisma 6.19 samt Grund, warum die
Umstellung auf prisma.config.ts noch aussteht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:52 +02:00
florian.wildeandClaude Opus 5 3de886c5e1 chore: ungenutzte experimental.serverActions-Option entfernen
Die Anwendung nutzt ausschliesslich Route Handler, keine Server Actions. Die
Option bewirkte nichts, liess Next beim Build aber "Experiments (use with
caution)" ausgeben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 220298f50d fix(docker): Prisma-CLI im Laufzeit-Image lauffaehig machen
Der App-Container brach beim Start mit "Cannot find module 'effect'" ab und
lief in die Wiederholschleife des Entrypoints. Ursache: aus dem Builder
wurden nur node_modules/prisma und node_modules/@prisma uebernommen. Die CLI
laedt aber @prisma/config, und das zieht einen eigenen Abhaengigkeitsbaum
(effect, c12, …) nach, der ausserhalb dieser beiden Verzeichnisse liegt.

Statt einzelne Pakete nachzukopieren bekommt die CLI jetzt eine eigene,
minimale Installation unter /opt/prisma-cli. Die Version wird aus der
package.json des Builders gelesen, damit CLI und generierter Client nicht
auseinanderlaufen. Der Entrypoint bricht ausserdem mit klarer Meldung ab,
wenn die CLI fehlt, statt fuenfzehnmal auf eine vermeintlich nicht bereite
Datenbank zu warten.

Verifiziert: docker compose --profile full up -d --build spielt die
Migrationen ein, der Container meldet sich als healthy und erreicht die
Git-APIs nach aussen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 be9347e01f chore: package-lock.json ergaenzen und next-env.d.ts aktualisieren
Der Lockfile fixiert die tatsaechlich aufgeloesten Versionen (Next 15.5.21,
Prisma 6.19.3, React 19) und ist Voraussetzung fuer "npm ci" im
Docker-Build. next-env.d.ts wurde beim ersten Build von Next um die
Routen-Typen ergaenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
florian.wildeandClaude Opus 5 6a792623d5 refactor: ungenutzte StatusBadge-Komponente entfernen
Board und Detailseite benutzen durchgaengig StatusSelect, das die Pille
bereits rendert und zusaetzlich den Wechsel erlaubt. Die reine Anzeige-
variante hatte damit keinen Aufrufer mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:45 +02:00
florian.wildeandClaude Opus 5 cb4dbfdf9f docs: README und Funktionsuebersicht
README als Einstieg: was Nebula ist, drei Wege zur Inbetriebnahme
(Standalone, komplett Docker, eigene PostgreSQL), Tabelle aller
Umgebungsvariablen, Anleitung fuer die Git-Verbindung inklusive der noetigen
Token-Scopes je System, Projektstruktur, npm-Skripte, API-Uebersicht,
Sicherheitshinweise und eine Fehlersuchtabelle.

FEATURES.md listet den Funktionsumfang vollstaendig nach Bereichen auf:
Projekte und Status, Dashboard, Notizen, Git-Anbindung inklusive der beim
Sync geholten Daten, Oberflaeche sowie Datenmodell, Sicherheit und Betrieb.
Am Ende stehen bewusst nicht umgesetzte Ideen fuer spaeter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:14 +02:00
florian.wildeandClaude Opus 5 c781c5d19d build: Docker-Betrieb und Standalone-Betrieb gleichwertig unterstuetzen
Dieselbe Codebasis laeuft nativ auf dem Rechner oder komplett im Container.

docker-compose.yml mit Profilen statt mehrerer Dateien:
- "db" laeuft immer  -> docker compose up -d db, App dann lokal per npm
- --profile full     -> zusaetzlich der App-Container
- --profile tools    -> Adminer als Datenbankoberflaeche

Dockerfile als Multi-Stage-Build auf dem Next-Standalone-Output. In die
Laufzeitschicht wandern zusaetzlich Prisma-CLI, Engines und Schema, damit der
Entrypoint beim Start "migrate deploy" ausfuehren kann. Das Alpine-Image
bekommt openssl fuer die Prisma-Engines, der Client wird zusaetzlich fuer
linux-musl-openssl-3.0.x erzeugt. Der Container laeuft als unprivilegierter
Benutzer und meldet seinen Zustand ueber /api/health.

Der Entrypoint wartet in einer Schleife auf die Datenbank, ergaenzend zum
pg_isready-Healthcheck und der depends_on-Bedingung.

scripts/setup.mjs legt die .env aus der Vorlage an und erzeugt dabei ein
zufaelliges APP_SECRET, damit die Tokens nicht mit dem Standardschluessel
verschluesselt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:04 +02:00
florian.wildeandClaude Opus 5 72674dee4e feat(ui): Verwaltung der Git-Verbindungen
Eine Seite fuer alle angebundenen Git-Systeme. Mehrere Verbindungen desselben
Typs sind moeglich, etwa privates GitHub und Firmen-Gitea nebeneinander.

- Anlegen mit Systemauswahl, Basis-URL und Access Token; die Basis-URL wird
  beim Wechsel des Systems auf den passenden Standard gesetzt, sofern sie
  nicht von Hand geaendert wurde
- Verbindungstest auf Knopfdruck: prueft Erreichbarkeit und Token, meldet den
  erkannten Benutzer und bei Gitea die Serverversion. Ergebnis und Zeitpunkt
  werden gespeichert und in der Karte angezeigt
- Token erscheint nur als Hinweis; ein leeres Feld beim Bearbeiten laesst ihn
  unveraendert
- Loeschen trennt nur die Verknuepfung, die Projekte bleiben erhalten
- Hinweis auf die noetigen Scopes samt Link zur Doku des jeweiligen Systems

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:51 +02:00
florian.wildeandClaude Opus 5 826706aeca feat(ui): Projektdetail mit Notizen und Git-Panel
Kopfbereich mit Statusauswahl samt Erklaerung je Zustand, Favorit,
Fortschrittsregler, Bearbeiten und Loeschen.

Notizen:
- beliebig viele je Projekt, Titel optional, Zeilenumbrueche bleiben erhalten
- anpinnen, bearbeiten, loeschen direkt in der Liste
- "bearbeitet vor ..." erscheint nur bei echter Aenderung, nicht direkt nach
  dem Anlegen

Git-Panel:
- Kennzahlen zu Stars, Forks, offenen PRs/MRs, Issues, Branches und Groesse
- letzter Commit und aktuelle Version als eigene Kacheln
- Sprachverteilung als Balken
- Reiter fuer Branches, Tags, Commits und Releases mit Links ins Repo
- "Jetzt synchronisieren" holt den Stand; Fehler erscheinen im Klartext statt
  als Fehlerseite

Ohne verknuepftes Repository zeigt das Panel einen Hinweis statt leerer
Kacheln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:40 +02:00
florian.wildeandClaude Opus 5 62b5c5bfa6 feat(ui): Dashboard mit Board, Filtern und direktem Statuswechsel
- Fuenf Kennzahlen ueber dem Board: Projektzahl mit Durchschnittsfortschritt,
  Ideen, aktive Entwicklungen, fertige Projekte, verknuepfte Repos
- Volltextsuche ueber Name, Beschreibung, Tags und Repo-Pfad
- Statusfilter als Chips mit Trefferzaehler, mehrfach kombinierbar
- Sortierung nach Aenderung, Anlage, Name, Fortschritt oder Prioritaet;
  Favoriten stehen immer oben
- Zwei Ansichten: freies Raster oder nach Status gruppiert
- Archiv standardmaessig ausgeblendet, per Schalter sichtbar

Die Projektkarte zeigt Fortschritt, Provider, Default-Branch, letzten Tag,
Alter des letzten Commits, Notizzahl und Sync-Fehler. Status und Favorit sind
direkt auf der Karte aenderbar - beides optimistisch, mit Rollback wenn der
Server ablehnt. Die Karte ist ein "stretched link", damit die Schaltflaechen
darin nicht in einem Anker verschachtelt sind.

Der Anlege-Dialog verlangt nur einen Namen; neue Projekte starten als Idee.
Ist eine Git-Verbindung gewaehlt, laesst sich das Repository aus einer
durchsuchbaren Liste uebernehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:29 +02:00
florian.wildeandClaude Opus 5 6fcbc04110 feat(ui): Dark-Glassmorphism-Designsystem und Grundlayout
Optischer Rahmen der Anwendung:

- Hintergrund aus drei langsam driftenden Nebeln, feinem Raster und einer
  Rauschtextur gegen Farbbanding
- .glass / .glass-soft als Grundflaechen, .card-glow fuer den Hover-Effekt
  mit Farbverlaufsrand je Projektakzent
- Buttons, Formularfelder und Scrollbars im gleichen Stil
- prefers-reduced-motion schaltet saemtliche Animationen ab

Dazu Kopfzeile mit aktiver Navigationsmarkierung, ein wiederverwendbarer
Modal-Dialog (Escape schliesst, Scroll wird gesperrt), das SVG-Icon sowie
Fehler- und 404-Seiten. Die Fehlerseite erkennt Datenbankprobleme und nennt
direkt die drei Befehle, die in dem Fall meistens helfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:15 +02:00
florian.wildeandClaude Opus 5 7690bc87c5 feat(api): REST-Endpunkte fuer Projekte, Notizen, Verbindungen und Sync
Alles, was die Oberflaeche kann, ist damit auch scriptbar:

  GET  POST           /api/projects
  GET  PATCH DELETE   /api/projects/:id
  POST                /api/projects/:id/sync
  GET  POST           /api/projects/:id/notes
  PATCH DELETE        /api/notes/:id
  GET  POST           /api/connections
  PATCH DELETE        /api/connections/:id
  POST                /api/connections/:id/check
  GET                 /api/connections/:id/repos
  GET                 /api/health

Details:
- Jede Eingabe laeuft durch Zod; Feldfehler kommen als Objekt zurueck, damit
  Formulare sie direkt anzeigen koennen.
- Beim Anlegen werden Owner und Repo-Name aus der URL abgeleitet, falls nicht
  explizit angegeben.
- Ein leeres Token-Feld beim Bearbeiten einer Verbindung laesst das
  gespeicherte Token unveraendert; null entfernt es.
- Verbindungsantworten enthalten nie das Chiffrat, nur einen Hinweis
  wie "ghp_...9f3a".
- /api/health prueft die Datenbank und dient dem Docker-Healthcheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:02 +02:00
florian.wildeandClaude Opus 5 9ea6bb8d36 feat(git): Adapter fuer GitHub, GitLab und Gitea
Ein gemeinsames Interface (GitClient) mit drei Implementierungen. Jeder
Adapter liefert dieselbe normalisierte Struktur, sodass die Oberflaeche die
Unterschiede der APIs nicht kennen muss:

- GitHub  – REST v3, Bearer-Token, funktioniert auch mit Enterprise (/api/v3).
            open_issues_count enthaelt dort PRs, das wird herausgerechnet.
- GitLab  – API v4, PRIVATE-TOKEN, Projekte ueber den URL-kodierten Pfad,
            damit verschachtelte Gruppen funktionieren.
- Gitea   – API v1, token-Header, deckt auch Forgejo ab.

Geholt werden Repo-Eckdaten, Branches, Tags, Commits, Releases, Sprachen und
offene PRs/MRs. Die Teilabfragen laufen parallel; eine fehlschlagende Liste
bricht den restlichen Sync nicht ab.

Weiteres:
- http.ts uebersetzt HTTP-Status in verstaendliche Meldungen (401 Token,
  403 Scopes/Rate-Limit, 404 Pfad) und erzwingt ein Timeout.
- parseRepoUrl versteht https-, ssh- und GitLab-Weboberflaechen-URLs.
- meta.ts haelt die Provider-Metadaten frei von node:crypto, damit
  Client-Komponenten sie importieren koennen.
- sync.ts schreibt das Ergebnis als Snapshot und vermerkt Fehler am Projekt,
  statt eine Ausnahme bis in die Seite durchzureichen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:50 +02:00
florian.wildeandClaude Opus 5 c9e09a2762 feat(core): Verschluesselung, Validierung und gemeinsame Helfer
- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt
  aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>),
  damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt
  die App und faellt auf einen Entwicklungsschluessel zurueck.
- validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive
  Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche.
- status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer
  Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen.
- utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone
  (verhindert Abweichungen zwischen Server-Render und Hydration) und
  relative Zeitangaben.
- projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst
  tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt.
- db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim
  Hot Reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:35 +02:00
florian.wildeandClaude Opus 5 58c493ddbd feat(db): Prisma-Datenmodell fuer Projekte, Notizen und Git-Verbindungen
Vier Tabellen in PostgreSQL:

- Project: Stammdaten, Status, Prioritaet, Fortschritt, Tags, Akzentfarbe
  und die optionale Repo-Verknuepfung
- Note: beliebig viele Notizen je Projekt, anpinnbar, Cascade-Delete
- GitConnection: ein Eintrag je Git-System inkl. verschluesseltem Token
- RepoSnapshot: zwischengespeicherter Repo-Stand, damit das Dashboard
  auch ohne Netz oder bei erreichtem Rate-Limit Daten zeigt

ProjectStatus deckt den Lebenszyklus von der Idee bis zum Archiv ab.
Branch-, Tag- und Commit-Listen liegen als JSONB, damit providerspezifische
Felder nicht verloren gehen.

Enthaelt die Initial-Migration (haendisch gepflegt, damit "prisma migrate
deploy" im Container ohne Shadow-Datenbank durchlaeuft) und einen
idempotenten Seed mit drei Beispielprojekten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:23 +02:00
florian.wildeandClaude Opus 5 ceafcc6d1e chore: Projektgeruest mit Next.js 15, TypeScript und Tailwind CSS 4
Grundlage fuer Nebula, das Dashboard fuer Vibe-Coding Projekte.

- Next.js 15 (App Router) mit React 19 und TypeScript im strict-Modus
- Tailwind CSS 4 ueber @tailwindcss/postcss, ohne separate Config-Datei
- output: "standalone", damit dasselbe Build sowohl nativ als auch im
  Docker-Image laeuft
- Pfad-Alias @/* auf src/
- .gitattributes erzwingt LF fuer Shell-Skripte (wichtig fuers Image)
- MIT-Lizenz

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:12 +02:00
65 changed files with 7727 additions and 169 deletions
+9
View File
@@ -43,6 +43,15 @@ yarn-error.log*
/data
/postgres-data
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
/backups
# Playwright
/test-results
/playwright-report
/blob-report
/playwright/.cache
# editors
.idea/
.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.
+126 -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.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
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.
- **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
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
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
- **Markdown wird dargestellt** — Überschriften, Listen, Tabellen, Codeblöcke, Zitate und
Links. Aus `- [ ] Aufgabe` werden echte Checklisten. Gilt auch für die Projekt-
beschreibung. Rohes HTML wird beim Rendern entfernt (`rehype-sanitize`) — der Inhalt
stammt vom Benutzer und wäre sonst ein XSS-Loch.
- **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
@@ -164,12 +219,28 @@ Firmen-Gitea nebeneinander.
| --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
| **Pull-/Merge-Requests** | bis zu 50 offene, mit Nummer, Titel, Autor, Alter, Labels, Entwurfskennzeichnung und Link |
| **Issues** | bis zu 50 offene, gleiche Angaben. Bei GitHub und Gitea werden PRs herausgefiltert, die dort in derselben Liste stecken |
| **CI-Status** | Letzter Lauf auf dem Default-Branch: erfolgreich, fehlgeschlagen, läuft, wartet oder abgebrochen — mit Name und Link |
| Branches | bis zu 50, mit SHA, Default-Markierung, Schutzstatus und (wo die API es liefert) letztem Commit |
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15, 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 |
| Sprachen | Verteilung als Balken mit Prozentwerten |
Woher der CI-Status kommt:
| System | Quelle |
| --- | --- |
| GitHub | `actions/runs` für den Default-Branch; Lauf-Status und Ergebnis werden zusammengeführt |
| GitLab | `pipelines` für den Default-Branch |
| Gitea | Kombinierter Commit-Status — deckt Gitea Actions **und** externe Systeme wie Woodpecker ab, anders als ein reiner Actions-Endpunkt, den ältere Instanzen nicht kennen |
Zur Ehrlichkeit der Aktivitätskurve: geholt wird eine Seite mit 100 Commits. Reicht die
nicht über den ganzen Zeitraum, wird die Kurve auf den tatsächlich belegten Ausschnitt
gekürzt — eine Null zu zeichnen, wo wir schlicht nichts wissen, wäre falsch.
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
@@ -215,6 +286,42 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
---
## 4a. Export, Import und Sicherung
### Projekte mitnehmen
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
bekannte Git-Stand als lesbare Datei.
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
| Strategie | Wirkung |
| --- | --- |
| Überspringen | Vorhandene bleiben unverändert |
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
hin. Erst ein zweiter Klick schreibt.
### Sicherung
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
kurzlebig, letztere hängen an Gerät und rpID.
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
ohne die echte anzufassen.
---
## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
@@ -241,6 +348,7 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
@@ -292,7 +400,22 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 6.4 API
### 6.4 Tests
`npm test` führt die Unit-Tests aus — reine Logik, kein DOM, keine Datenbank, unter einer
Sekunde:
| 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 |
Bewusst ohne Datenbank- und Browsertests: die brauchen Infrastruktur und gehören in eine
CI-Pipeline. Das steht in der [ROADMAP](ROADMAP.md).
### 6.5 API
Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health.
Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
+67
View File
@@ -199,6 +199,63 @@ Wer lieber von außen taktet, lässt den Automatismus aus und ruft
---
## Export, Import und Sicherung
### Projekte mitnehmen
| Weg | Wo | Inhalt |
| --- | --- | --- |
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
angelegt und in der Vorschau angezeigt.
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
- **Überspringen** — Vorhandene bleiben unverändert
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
### Sicherung
Zwei Wege, mit unterschiedlichem Zweck:
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
vertraulich behandeln.
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
Instanz braucht:
```powershell
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
.\scripts\restore.ps1 -File .\backups\nebula-....sql
```
```bash
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
./scripts/restore.sh backups/nebula-....sql
```
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
Container aus — auf dem Rechner selbst muss nichts installiert sein.
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
>
> ```powershell
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
> ```
## Projektstruktur
```
@@ -241,6 +298,8 @@ nebula/
| `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
| `npm test` | Unit-Tests (Vitest), unter einer Sekunde |
| `npm run test:watch` | Tests im Beobachtungsmodus |
| `npm run typecheck` | TypeScript prüfen |
| `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
@@ -271,8 +330,16 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `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 |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen |
+39 -81
View File
@@ -11,122 +11,68 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
## 1 · Als Nächstes empfohlen
### 1.1 Export und Import `M`
### 1.1 Tests ausbauen: Datenbank und Browser `L`
Dein Vorschlag, in vier Stufen — jede für sich nutzbar:
Das Fundament steht — 51 Unit-Tests der reinen Logik laufen in unter einer
Sekunde. Was fehlt, ist die Schicht darüber:
| Stufe | Inhalt |
| --- | --- |
| **Projekt-Export** | Ein Projekt oder alle als JSON: Stammdaten, Notizen, Repo-Verknüpfung. Ein Klick auf der Projektseite, einer im Dashboard für alles. |
| **Projekt-Import** | JSON hochladen, Vorschau, dann übernehmen. Konfliktstrategie wählbar: überspringen · umbenennen · überschreiben. |
| **Markdown-Export** | Ein Projekt als lesbares `.md` — Beschreibung, Notizen, aktueller Git-Stand. Gut zum Archivieren oder für ein README. |
| **Voll-Backup** | `pg_dump` aus dem Container heraus, per Knopf im Adminbereich als Datei. Plus dokumentierter Rückweg. |
- **Integrationstests** gegen eine Wegwerf-Datenbank: Datentrennung zwischen
Konten, Import-Strategien, Backoff des Abgleichs
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung, Kanban
- **CI-Pipeline** bei GitHub oder Gitea Actions
**Offene Fragen, die wir klären sollten:**
- **Git-Tokens mitexportieren?** Mein Vorschlag: **nein**. Die Verbindung wird
mitexportiert (Name, System, Basis-URL), das Token muss beim Import neu
hinterlegt werden. Ein Export-JSON landet sonst schnell irgendwo, wo es
nicht hingehört.
- **Reichweite:** Export der eigenen Daten für jeden — Voll-Backup nur für
Administratoren?
- **Snapshots mitnehmen?** Ich würde sie weglassen: sie sind reproduzierbar
und blähen die Datei auf. Ein Sync nach dem Import holt sie zurück.
Ich prüfe bislang alles von Hand, inklusive Screenshots. Das trägt nicht
dauerhaft und macht jede weitere Änderung teurer als nötig.
### 1.2 Markdown in Beschreibung und Notizen `S`
### 1.2 Benachrichtigungen `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.3 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.4 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.
Bei Sync-Fehlern, roter CI, neuen Releases oder Projekten, die lange
stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
dem automatischen Abgleich und dem CI-Status ergibt das ein brauchbares
Frühwarnsystem — die Daten liegen bereits vor.
---
## 2 · Danach
### 2.1 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`
### 2.1 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
### 2.4 Commit-Aktivität als Verlauf `S`
### 2.2 Commit-Aktivität als Heatmap `S`
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
da, es fehlt nur die Darstellung.
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.5 Aufgaben und Checklisten `M`
### 2.3 Kanban mit freier Reihenfolge `M`
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
### 2.6 Benachrichtigungen `M`
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
---
## 3 · Größere Brocken
### 3.1 Automatisierte Tests und CI `L`
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`
### 3.1 Mehrere Repositories je Projekt `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
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
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
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
Voraussetzung, falls du das Projekt veröffentlichen willst.
@@ -138,14 +84,17 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Idee | Aufwand | Nutzen |
| --- | --- | --- |
| 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 |
| 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 |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| 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 |
---
@@ -176,3 +125,12 @@ Damit klar ist, was **nicht** kommt und warum:
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
| Docker- und Standalone-Betrieb | ✓ |
| Automatischer Abgleich mit Zeitplan | ✓ |
| Export, Import und Sicherung | ✓ |
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
| **v0.5.0** Aufgabenbrett je Projekt mit sortierbaren Spalten, Fortschritt aus Aufgaben | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
+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/);
});
+2525 -5
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "0.1.0",
"version": "0.6.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -23,12 +23,20 @@
"docker:logs": "docker compose --profile full logs -f app",
"docker:tools": "docker compose --profile tools up -d",
"setup": "node scripts/setup.mjs",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "vitest run",
"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": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.8.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
@@ -36,9 +44,13 @@
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"zod": "^3.25.28"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21",
"@types/react": "^19.1.5",
@@ -46,7 +58,8 @@
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.11"
+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;
+49
View File
@@ -33,6 +33,14 @@ enum GitProvider {
GITEA
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole {
@@ -237,7 +245,12 @@ model Project {
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
createdAt DateTime @default(now())
@@ -250,6 +263,30 @@ model Project {
@@index([ownerId])
}
/// 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.
model Note {
id String @id @default(cuid())
@@ -303,6 +340,18 @@ model RepoSnapshot {
commits Json @default("[]")
releases Json @default("[]")
languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
pushedAt DateTime?
fetchedAt DateTime @default(now())
+64
View File
@@ -0,0 +1,64 @@
<#
.SYNOPSIS
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
.DESCRIPTION
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
man zum Wiederherstellen einer kaputten Instanz braucht.
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
.PARAMETER OutDir
Zielverzeichnis. Vorgabe: .\backups
.PARAMETER Container
Name des Postgres-Containers. Vorgabe: nebula-db
.EXAMPLE
.\scripts\backup.ps1
.\scripts\backup.ps1 -OutDir D:\Sicherungen
#>
[CmdletBinding()]
param(
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
[string]$Container = "nebula-db"
)
$ErrorActionPreference = "Stop"
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
$envFile = Join-Path $PSScriptRoot "..\.env"
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
$settings[$Matches[1]] = $Matches[2].Trim()
}
}
}
$user = $settings["POSTGRES_USER"]
$database = $settings["POSTGRES_DB"]
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
$target = Join-Path $OutDir "nebula-$stamp.sql"
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
if ($running -notcontains $Container) {
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
}
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
#
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
#
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
out_dir="${1:-$root/backups}"
container="${NEBULA_DB_CONTAINER:-nebula-db}"
user="nebula"
database="nebula"
if [ -f "$root/.env" ]; then
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
value_of() {
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
"$root/.env" | tail -n 1
}
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
fi
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
exit 1
fi
mkdir -p "$out_dir"
stamp="$(date +%Y-%m-%d-%H%M%S)"
target="$out_dir/nebula-$stamp.sql"
echo "Sichere Datenbank '$database' aus Container '$container'..."
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
size="$(du -h "$target" | cut -f1)"
echo "Fertig: $target ($size)"
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
+62
View File
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
.DESCRIPTION
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
fragt vorher nach, es sei denn, -Force ist gesetzt.
.PARAMETER Database
Zieldatenbank. Vorgabe: der Wert aus der .env.
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
.EXAMPLE
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$File,
[string]$Database,
[string]$Container = "nebula-db",
[switch]$Force
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
$envFile = Join-Path $PSScriptRoot "..\.env"
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
$settings[$Matches[1]] = $Matches[2].Trim()
}
}
}
$user = $settings["POSTGRES_USER"]
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
if (-not $Force) {
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
}
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
if ($running -notcontains $Container) {
Write-Error "Container '$Container' laeuft nicht."
}
Write-Host "Spiele '$File' ein..."
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
#
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
#
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
#
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
#
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
file="${1:-}"
force="${2:-}"
container="${NEBULA_DB_CONTAINER:-nebula-db}"
if [ -z "$file" ]; then
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "Datei nicht gefunden: $file" >&2
exit 1
fi
user="nebula"
database="nebula"
if [ -f "$root/.env" ]; then
value_of() {
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
"$root/.env" | tail -n 1
}
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
fi
database="${NEBULA_DB_NAME:-$database}"
if [ "$force" != "--force" ]; then
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
read -r answer
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
fi
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
echo "Container '$container' laeuft nicht." >&2
exit 1
fi
echo "Spiele '$file' ein..."
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
+89
View File
@@ -0,0 +1,89 @@
import { requireAdminApi } from "@/lib/auth/guard";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format";
export const dynamic = "force-dynamic";
/**
* Vollstaendige Sicherung der Instanz als JSON.
*
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
* deshalb auch Konten und Verbindungen. Was drinsteht:
*
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
* Passkeys haengen ohnehin am Geraet und an der rpID
*
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
* der Datenbank siehe scripts/backup.* mit pg_dump.
*/
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const [settings, users, connections, projects] = await Promise.all([
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
prisma.user.findMany({
select: {
id: true,
username: true,
displayName: true,
email: true,
passwordHash: true,
role: true,
isActive: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { username: "asc" },
}),
prisma.gitConnection.findMany({
select: {
id: true,
ownerId: true,
name: true,
provider: true,
baseUrl: true,
username: true,
tokenCipher: true,
tokenHint: true,
createdAt: true,
},
orderBy: { name: "asc" },
}),
prisma.project.findMany({
include: {
notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross.
},
orderBy: { name: "asc" },
}),
]);
const payload = {
kind: "nebula.backup",
version: 1,
exportedAt: new Date().toISOString(),
app: APP_NAME,
note:
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
settings,
users,
connections,
projects,
};
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format";
import { buildExportFile, toMarkdown } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Ein einzelnes Projekt exportieren.
*
* ?format=json (Vorgabe) – zum Wiedereinlesen
* ?format=md – lesbar, zum Archivieren
*/
export async function GET(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const format = new URL(request.url).searchParams.get("format") ?? "json";
const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
});
if (!project) return jsonError("Projekt nicht gefunden", 404);
if (format === "md" || format === "markdown") {
const filename = exportFileName(project.slug, new Date(), "md");
return new Response(toMarkdown(project), {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
const payload = buildExportFile([project]);
const filename = exportFileName(project.slug, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
+11 -2
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -75,6 +76,7 @@ export async function PATCH(request: Request, { params }: Params) {
"tags",
"favorite",
"autoSync",
"progressFromTasks",
"connectionId",
] as const) {
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.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 },
data: update,
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 });
}
+29
View File
@@ -0,0 +1,29 @@
import { requireUserApi } from "@/lib/auth/guard";
import { APP_NAME } from "@/lib/config";
import { exportFileName } from "@/lib/export/format";
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Alle eigenen Projekte als JSON zum Herunterladen. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const projects = await loadProjectsForExport(auth.user.id);
if (projects.length === 0) {
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
}
const payload = buildExportFile(projects);
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
return new Response(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
+240
View File
@@ -0,0 +1,240 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import {
IMPORT_MAX_BODY_BYTES,
clientIp,
jsonError,
readJsonBody,
tooManyRequests,
} from "@/lib/security/request";
import { slugify } from "@/lib/utils";
export const dynamic = "force-dynamic";
const requestSchema = z.object({
data: z.unknown(),
/**
* skip – vorhandene Projekte unveraendert lassen
* rename – zusaetzlich anlegen, mit freiem Slug
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
*/
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
/** Nur pruefen und berichten, nichts schreiben. */
dryRun: z.boolean().default(true),
});
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
interface ItemResult {
name: string;
slug: string;
action: Action;
/** Bei "rename" der tatsaechlich vergebene Slug. */
newSlug?: string;
notes: number;
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
connection: "matched" | "missing" | "none";
connectionName?: string;
message?: string;
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`import:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
if (!body.ok) return body.response;
const outer = requestSchema.safeParse(body.data);
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
// damit passiert.
const parsed = exportFileSchema.safeParse(outer.data.data);
if (!parsed.success) {
const first = parsed.error.issues[0];
return NextResponse.json(
{
error:
"Die Datei passt nicht zum erwarteten Format. " +
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
},
{ status: 422 },
);
}
const { strategy, dryRun } = outer.data;
const file = parsed.data;
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
const connections = await prisma.gitConnection.findMany({
where: { ownerId: auth.user.id },
select: { id: true, name: true, provider: true, baseUrl: true },
});
function matchConnection(described: ExportedProject["connection"]) {
if (!described) return null;
const byName = connections.find(
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
);
if (byName) return byName;
return (
connections.find(
(candidate) =>
candidate.provider === described.provider &&
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
) ?? null
);
}
const results: ItemResult[] = [];
for (const incoming of file.projects) {
const slug = incoming.slug || slugify(incoming.name) || "projekt";
const connection = matchConnection(incoming.connection);
const connectionState: ItemResult["connection"] = incoming.connection
? connection
? "matched"
: "missing"
: "none";
const existing = await prisma.project.findFirst({
where: { slug, ownerId: auth.user.id },
select: { id: true },
});
const base: ItemResult = {
name: incoming.name,
slug,
action: "create",
notes: incoming.notes.length,
connection: connectionState,
connectionName: incoming.connection?.name,
};
if (existing && strategy === "skip") {
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
continue;
}
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
if (dryRun) {
results.push({
...base,
action,
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
...(action === "overwrite"
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
: {}),
});
continue;
}
const data = {
name: incoming.name,
summary: incoming.summary ?? null,
description: incoming.description ?? null,
status: incoming.status,
priority: incoming.priority,
progress: incoming.progress,
accent: incoming.accent,
tags: incoming.tags,
favorite: incoming.favorite,
autoSync: incoming.autoSync,
repoUrl: incoming.repoUrl ?? null,
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? 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) => ({
title: note.title ?? null,
content: note.content,
pinned: note.pinned ?? false,
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
? { createdAt: new Date(note.createdAt) }
: {}),
}));
try {
if (existing && action === "overwrite") {
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
// erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}),
]);
results.push({ ...base, action: "overwrite" });
} else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({
data: {
...data,
ownerId: auth.user.id,
slug: finalSlug,
notes: { create: notes },
tasks: { create: tasks },
},
});
results.push({
...base,
action: existing ? "rename" : "create",
...(existing ? { newSlug: finalSlug } : {}),
});
}
} catch (error) {
results.push({
...base,
action: "error",
message: error instanceof Error ? error.message : String(error),
});
}
}
const summary = {
total: results.length,
created: results.filter((entry) => entry.action === "create").length,
renamed: results.filter((entry) => entry.action === "rename").length,
overwritten: results.filter((entry) => entry.action === "overwrite").length,
skipped: results.filter((entry) => entry.action === "skip").length,
errors: results.filter((entry) => entry.action === "error").length,
missingConnections: results.filter((entry) => entry.connection === "missing").length,
};
return NextResponse.json({ dryRun, strategy, summary, results });
}
+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 });
}
+125
View File
@@ -89,6 +89,131 @@
0 28px 70px -24px rgba(0, 0, 0, 0.95);
}
/**
* Markdown-Inhalte. Tailwind setzt in seinem Preflight alle Abstaende und
* Schriftgroessen zurueck, also muessen Ueberschriften, Listen und
* Codebloecke hier wieder Form bekommen.
*/
.md {
font-size: 0.875rem;
line-height: 1.65;
color: #cbd5e1;
overflow-wrap: break-word;
}
.md > *:first-child { margin-top: 0; }
.md > *:last-child { margin-bottom: 0; }
.md h1, .md h2, .md h3, .md h4 {
color: #f1f5f9;
font-weight: 600;
line-height: 1.3;
margin: 1.4em 0 0.5em;
}
.md h1 { font-size: 1.3rem; }
.md h2 { font-size: 1.15rem; }
.md h3 { font-size: 1rem; }
.md h4 { font-size: 0.9rem; }
.md p { margin: 0.75em 0; }
.md a {
color: #c4b5fd;
text-decoration: underline;
text-underline-offset: 2px;
}
.md a:hover { color: #ddd6fe; }
.md strong { color: #f1f5f9; font-weight: 600; }
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
.md ul { list-style: disc; }
.md ol { list-style: decimal; }
.md li { margin: 0.3em 0; }
.md li::marker { color: #64748b; }
/* Aufgabenlisten aus "- [ ] …" */
.md li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.35em;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.md input[type="checkbox"] {
margin-top: 0.35em;
accent-color: #8b5cf6;
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
}
.md code {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 0.35rem;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
color: #e9d5ff;
}
.md pre {
background: rgba(4, 6, 16, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
padding: 0.85rem 1rem;
margin: 0.9em 0;
overflow-x: auto;
}
.md pre code {
background: none;
border: none;
padding: 0;
color: #cbd5e1;
font-size: 0.8rem;
line-height: 1.6;
}
.md blockquote {
border-left: 2px solid rgba(167, 139, 250, 0.5);
padding-left: 0.9rem;
margin: 0.9em 0;
color: #94a3b8;
font-style: italic;
}
.md hr {
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.09);
margin: 1.4em 0;
}
.md table {
width: 100%;
margin: 0.9em 0;
border-collapse: collapse;
font-size: 0.8rem;
display: block;
overflow-x: auto;
}
.md th, .md td {
border: 1px solid rgba(255, 255, 255, 0.09);
padding: 0.4rem 0.6rem;
text-align: left;
}
.md th {
background: rgba(255, 255, 255, 0.04);
color: #e2e8f0;
font-weight: 600;
}
.md img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
/* ── Text & Akzente ───────────────────────────────────────── */
.text-gradient {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata, Viewport } from "next";
import { TopNav } from "@/components/TopNav";
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";
/**
@@ -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">
<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 className="font-mono text-slate-600">v0.2.0</span>
<span className="font-mono text-slate-600">v{APP_VERSION}</span>
</div>
</footer>
</body>
+9 -3
View File
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { FileText } from "lucide-react";
import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
@@ -58,6 +60,12 @@ export default async function ProjectPage({ params }: PageProps) {
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="space-y-6 lg:col-span-5">
<section className="glass p-5">
@@ -66,9 +74,7 @@ export default async function ProjectPage({ params }: PageProps) {
Beschreibung
</h2>
{detail.description ? (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{detail.description}
</p>
<Markdown className="mt-3">{detail.description}</Markdown>
) : (
<p className="mt-3 text-sm text-slate-500">
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
+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 { useRouter } from "next/navigation";
import {
Activity,
AlertTriangle,
CircleDot,
ExternalLink,
GitBranch,
GitCommitHorizontal,
@@ -18,6 +20,8 @@ import {
Star,
Tag,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { asArray, asRecord } from "@/types";
import type {
BranchInfo,
@@ -25,10 +29,13 @@ import type {
ProjectDetail,
ReleaseInfo,
TagInfo,
TicketInfo,
} from "@/types";
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
const TABS = [
{ key: "pulls", label: "PRs", icon: GitPullRequest },
{ key: "issues", label: "Issues", icon: CircleDot },
{ key: "branches", label: "Branches", icon: GitBranch },
{ key: "tags", label: "Tags", icon: Tag },
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
@@ -39,7 +46,7 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) {
const router = useRouter();
const [tab, setTab] = useState<TabKey>("branches");
const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false);
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 releases = asArray<ReleaseInfo>(snapshot?.releases);
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);
@@ -118,6 +128,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt
@@ -224,6 +235,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</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 */}
{languageTotal > 0 ? (
<div className="glass p-4">
@@ -263,14 +292,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<div className="glass overflow-hidden">
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
{TABS.map(({ key, label, icon: Icon }) => {
const count =
key === "branches"
? branches.length
: key === "tags"
? tags.length
: key === "commits"
? commits.length
: releases.length;
const count = {
pulls: pulls.length,
issues: issues.length,
branches: branches.length,
tags: tags.length,
commits: commits.length,
releases: releases.length,
}[key];
return (
<button
key={key}
@@ -292,6 +321,16 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div>
<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 === "tags" ? <TagList tags={tags} /> : 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>;
}
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[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return (
+326
View File
@@ -0,0 +1,326 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
FileJson,
Loader2,
Upload,
} from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/utils";
type Strategy = "skip" | "rename" | "overwrite";
interface ItemResult {
name: string;
slug: string;
action: "create" | "rename" | "overwrite" | "skip" | "error";
newSlug?: string;
notes: number;
connection: "matched" | "missing" | "none";
connectionName?: string;
message?: string;
}
interface ImportResponse {
dryRun: boolean;
strategy: Strategy;
summary: {
total: number;
created: number;
renamed: number;
overwritten: number;
skipped: number;
errors: number;
missingConnections: number;
};
results: ItemResult[];
}
const STRATEGIES: Array<{ value: Strategy; label: string; hint: string }> = [
{ value: "skip", label: "Ueberspringen", hint: "Vorhandene bleiben unveraendert." },
{ value: "rename", label: "Zusaetzlich anlegen", hint: "Doppelte bekommen einen freien Slug." },
{ value: "overwrite", label: "Ersetzen", hint: "Stammdaten und Notizen werden ueberschrieben." },
];
const ACTION_LABEL: Record<ItemResult["action"], { text: string; className: string }> = {
create: { text: "neu", className: "bg-emerald-500/12 text-emerald-200" },
rename: { text: "zusaetzlich", className: "bg-cyan-500/12 text-cyan-200" },
overwrite: { text: "ersetzt", className: "bg-amber-500/12 text-amber-200" },
skip: { text: "uebersprungen", className: "bg-slate-500/12 text-slate-300" },
error: { text: "Fehler", className: "bg-rose-500/12 text-rose-200" },
};
export function ImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [payload, setPayload] = useState<unknown>(null);
const [strategy, setStrategy] = useState<Strategy>("skip");
const [preview, setPreview] = useState<ImportResponse | null>(null);
const [done, setDone] = useState<ImportResponse | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setFileName(null);
setPayload(null);
setPreview(null);
setDone(null);
setError(null);
setStrategy("skip");
if (fileRef.current) fileRef.current.value = "";
}
function close() {
reset();
onClose();
}
async function pickFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
setPreview(null);
setDone(null);
setFileName(file.name);
try {
const text = await file.text();
const parsed = JSON.parse(text);
setPayload(parsed);
await run(parsed, strategy, true);
} catch {
setPayload(null);
setError("Die Datei enthaelt kein gueltiges JSON.");
}
}
async function run(data: unknown, chosen: Strategy, dryRun: boolean) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data, strategy: chosen, dryRun }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Import fehlgeschlagen.");
if (dryRun) setPreview(body as ImportResponse);
else {
setDone(body as ImportResponse);
router.refresh();
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
async function changeStrategy(next: Strategy) {
setStrategy(next);
if (payload) await run(payload, next, true);
}
const summary = done?.summary ?? preview?.summary;
const results = done?.results ?? preview?.results ?? [];
// Wuerde der Import ueberhaupt etwas veraendern? Sonst waere der Knopf ein
// Angebot, das ins Leere laeuft.
const wouldChange = preview
? preview.summary.created + preview.summary.renamed + preview.summary.overwritten > 0
: false;
return (
<Modal
open={open}
onClose={close}
width="xl"
title="Projekte importieren"
description="Eine zuvor exportierte JSON-Datei einlesen. Vor dem Schreiben gibt es eine Vorschau."
>
<div className="space-y-5">
{/* Datei */}
<div>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
onChange={pickFile}
className="hidden"
id="import-file"
/>
<button
type="button"
className="btn btn-ghost w-full justify-start py-3"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
<FileJson className="h-4 w-4 text-violet-300" />
{fileName ?? "JSON-Datei auswaehlen…"}
</button>
</div>
{/* Strategie */}
{payload ? (
<div>
<span className="field-label">Bei gleichem Slug</span>
<div className="grid gap-2 sm:grid-cols-3">
{STRATEGIES.map((option) => (
<button
key={option.value}
type="button"
disabled={busy || Boolean(done)}
onClick={() => changeStrategy(option.value)}
className={cn(
"rounded-xl border px-3 py-2.5 text-left transition-all disabled:opacity-60",
strategy === option.value
? "border-violet-400/60 bg-violet-500/12"
: "border-white/9 hover:border-white/20",
)}
>
<span className="block text-sm text-slate-100">{option.label}</span>
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
{option.hint}
</span>
</button>
))}
</div>
</div>
) : null}
{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}
{/* Vorschau oder Ergebnis */}
{summary ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-slate-400">
{done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e)
</span>
{summary.created > 0 ? <Chip tone="emerald">{summary.created} neu</Chip> : null}
{summary.renamed > 0 ? <Chip tone="cyan">{summary.renamed} zusaetzlich</Chip> : null}
{summary.overwritten > 0 ? (
<Chip tone="amber">{summary.overwritten} ersetzt</Chip>
) : null}
{summary.skipped > 0 ? <Chip tone="slate">{summary.skipped} uebersprungen</Chip> : null}
{summary.errors > 0 ? <Chip tone="rose">{summary.errors} Fehler</Chip> : null}
</div>
{summary.missingConnections > 0 ? (
<p className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
{summary.missingConnections} Projekt(e) verweisen auf eine Git-Verbindung,
die hier nicht existiert. Sie werden ohne Verknuepfung angelegt – lege die
Verbindung an und waehle sie im Projekt aus. Access Tokens sind in
Exportdateien grundsaetzlich nicht enthalten.
</span>
</p>
) : null}
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/8">
{results.map((item) => {
const label = ACTION_LABEL[item.action];
return (
<div
key={`${item.slug}-${item.name}`}
className="flex items-center justify-between gap-3 border-b border-white/5 px-3 py-2 last:border-b-0"
>
<div className="min-w-0">
<p className="truncate text-sm text-slate-200">{item.name}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-slate-500">
<span>{item.slug}</span>
{item.newSlug ? (
<>
<ArrowRight className="h-3 w-3" />
<span className="text-cyan-300">{item.newSlug}</span>
</>
) : null}
<span className="text-slate-600">· {item.notes} Notizen</span>
{item.connection === "missing" ? (
<span className="text-amber-300/80">
· Verbindung „{item.connectionName}“ fehlt
</span>
) : null}
</p>
{item.message ? (
<p className="mt-0.5 text-[11px] text-slate-600">{item.message}</p>
) : null}
</div>
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
label.className,
)}
>
{label.text}
</span>
</div>
);
})}
</div>
</div>
) : null}
{done ? (
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
<CheckCircle2 className="h-4 w-4" />
Import abgeschlossen.
</p>
) : null}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
{done ? "Schliessen" : "Abbrechen"}
</button>
{!done ? (
<button
type="button"
className="btn btn-primary"
disabled={busy || !wouldChange}
title={
preview && !wouldChange
? "Mit dieser Strategie wuerde sich nichts aendern."
: undefined
}
onClick={() => payload && run(payload, strategy, false)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Import ausfuehren
</button>
) : null}
</div>
</div>
</Modal>
);
}
function Chip({
tone,
children,
}: {
tone: "emerald" | "cyan" | "amber" | "slate" | "rose";
children: React.ReactNode;
}) {
const tones = {
emerald: "bg-emerald-500/12 text-emerald-200",
cyan: "bg-cyan-500/12 text-cyan-200",
amber: "bg-amber-500/12 text-amber-200",
slate: "bg-slate-500/12 text-slate-300",
rose: "bg-rose-500/12 text-rose-200",
};
return <span className={cn("rounded px-1.5 py-0.5", tones[tone])}>{children}</span>;
}
+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>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { cn } from "@/lib/utils";
/**
* Markdown-Darstellung fuer Beschreibungen und Notizen.
*
* Der Inhalt stammt vom Benutzer, wird aber unveraendert gerendert – ohne
* rehype-sanitize waere das ein XSS-Loch, sobald jemand rohes HTML in eine
* Notiz schreibt. Das Standardschema erlaubt nur harmlose Elemente; ergaenzt
* wird lediglich, was fuer Aufgabenlisten und Codebloecke noetig ist.
*/
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// Checkboxen aus "- [ ] Aufgabe"
input: [...(defaultSchema.attributes?.input ?? []), "checked", "disabled", "type"],
// Sprachklassen wie "language-ts" fuer Codebloecke
code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]],
},
};
export function Markdown({
children,
className,
}: {
children: string | null | undefined;
className?: string;
}) {
if (!children?.trim()) return null;
return (
<div className={cn("md", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]}
components={{
// Links aus fremdem Inhalt immer in neuem Tab und ohne
// Zugriff auf das oeffnende Fenster.
a: ({ href, children: content }) => (
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
{content}
</a>
),
}}
>
{children}
</ReactMarkdown>
</div>
);
}
+3 -4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, NotebookPen, Pencil, Pin, PinOff, Plus, Trash2, X } from "lucide-react";
import type { Note } from "@prisma/client";
import { Markdown } from "@/components/Markdown";
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */
@@ -118,7 +119,7 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
/>
<textarea
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
placeholder={"Gedanken, Todos, Snippets…\n\nMarkdown wird dargestellt:\n- [ ] offene Aufgabe\n**fett**, `code`, [Link](https://…)"}
value={content}
onChange={(event) => setContent(event.target.value)}
autoFocus
@@ -161,9 +162,7 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
{note.title}
</h3>
) : null}
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{note.content}
</p>
<Markdown className="mt-1">{note.content}</Markdown>
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
{formatDateTime(note.createdAt)}
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
+204 -5
View File
@@ -1,16 +1,58 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
Columns3,
Database,
Download,
LayoutGrid,
ListFilter,
NotebookPen,
Plus,
Rows3,
Search,
Upload,
X,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils";
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 ViewMode = "grid" | "grouped";
type ViewMode = "grid" | "grouped" | "kanban";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
@@ -33,6 +75,48 @@ export function ProjectBoard({
const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false);
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 map = new Map<ProjectStatus, number>();
@@ -56,7 +140,9 @@ export function ProjectBoard({
(project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().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)
);
});
@@ -77,7 +163,7 @@ export function ProjectBoard({
});
return sorted;
}, [projects, query, statusFilter, sort, showArchived]);
}, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]);
const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({
@@ -163,8 +249,33 @@ export function ProjectBoard({
>
<Rows3 className="h-4 w-4" />
</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>
<button
ref={dataButtonRef}
type="button"
onClick={() => setDataMenuOpen((value) => !value)}
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
aria-label="Daten exportieren oder importieren"
title="Export und Import"
aria-haspopup="menu"
aria-expanded={dataMenuOpen}
>
<Database className="h-4 w-4" />
</button>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span>
@@ -228,9 +339,54 @@ export function ProjectBoard({
</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 */}
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} />
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
@@ -267,11 +423,54 @@ export function ProjectBoard({
</div>
)}
<Popover
anchorRef={dataButtonRef}
open={dataMenuOpen}
onClose={() => setDataMenuOpen(false)}
width={260}
>
<a
href="/api/projects/export"
download
role="menuitem"
onClick={() => setDataMenuOpen(false)}
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<Download className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Alle Projekte exportieren
<span className="mt-0.5 block text-[11px] text-slate-500">
JSON mit Notizen, ohne Access Tokens
</span>
</span>
</a>
<button
type="button"
role="menuitem"
onClick={() => {
setDataMenuOpen(false);
setImportOpen(true);
}}
className="flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<Upload className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>
Projekte importieren
<span className="mt-0.5 block text-[11px] text-slate-500">
Mit Vorschau vor dem Schreiben
</span>
</span>
</button>
</Popover>
<ProjectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
connections={connections}
/>
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</section>
);
}
+24 -1
View File
@@ -4,16 +4,19 @@ import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
Tag,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
import { asArray, type ProjectListItem } from "@/types";
const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub",
@@ -29,6 +32,7 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot;
const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
@@ -110,8 +114,17 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</div>
</div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="mt-3">
<Sparkline values={activity} height={20} />
</div>
) : null}
{/* Git-Kurzinfo */}
<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 ? (
<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" />
@@ -143,6 +156,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span>
) : 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 ? (
<span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" />
+21 -1
View File
@@ -32,6 +32,7 @@ interface FormState {
repoOwner: string;
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
}
const EMPTY: FormState = {
@@ -49,6 +50,7 @@ const EMPTY: FormState = {
repoOwner: "",
repoName: "",
autoSync: true,
progressFromTasks: false,
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -90,6 +92,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "",
autoSync: project.autoSync,
progressFromTasks: project.progressFromTasks,
}
: EMPTY,
);
@@ -158,6 +161,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
};
try {
@@ -291,9 +295,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5}
value={form.progress}
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}
/>
<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 className="sm:col-span-2">
+71 -3
View File
@@ -1,12 +1,16 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
CheckSquare,
Download,
ExternalLink,
FileJson,
FileText,
Loader2,
Pencil,
Star,
@@ -14,6 +18,7 @@ import {
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatDate } from "@/lib/utils";
import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -30,6 +35,8 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite);
const [progress, setProgress] = useState(project.progress);
const [deleting, setDeleting] = useState(false);
@@ -162,6 +169,17 @@ export function ProjectHeader({
<div className="flex flex-col items-end gap-3">
<StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2">
<button
ref={exportButtonRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setExportOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={exportOpen}
title="Exportieren"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -190,7 +208,15 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */}
<div className="mt-6">
<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>
</div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
@@ -206,11 +232,53 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })}
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}
/>
</div>
<Popover
anchorRef={exportButtonRef}
open={exportOpen}
onClose={() => setExportOpen(false)}
width={250}
>
<a
href={`/api/projects/${project.id}/export`}
download
role="menuitem"
onClick={() => setExportOpen(false)}
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Als JSON
<span className="mt-0.5 block text-[11px] text-slate-500">
Zum Wiedereinlesen, mit Notizen
</span>
</span>
</a>
<a
href={`/api/projects/${project.id}/export?format=md`}
download
role="menuitem"
onClick={() => setExportOpen(false)}
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>
Als Markdown
<span className="mt-0.5 block text-[11px] text-slate-500">
Lesbar, inklusive Git-Stand
</span>
</span>
</a>
</Popover>
<ProjectDialog
open={editOpen}
onClose={() => setEditOpen(false)}
+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>
);
}
+37
View File
@@ -5,12 +5,15 @@ import { useRouter } from "next/navigation";
import {
Ban,
CheckCircle2,
Database,
Download,
Fingerprint,
FolderGit2,
Loader2,
Lock,
Plus,
RefreshCw,
ShieldAlert,
ShieldCheck,
Trash2,
User,
@@ -226,6 +229,40 @@ export function AdminManager({ data }: { data: AdminData }) {
</dl>
</section>
{/* ── Sicherung ───────────────────────────────────── */}
<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">
<Database className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Sicherung
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-400">
Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten,
Verbindungen, Projekte und Notizen.
</p>
</div>
</div>
<a href="/api/admin/backup" download className="btn btn-ghost">
<Download className="h-4 w-4" />
Sicherung herunterladen
</a>
<p className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens
sind nur mit demselben <code>APP_SECRET</code> wieder lesbar, Passwoerter
liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine
byteweise Sicherung der Datenbank gibt es <code>scripts/backup.ps1</code>{" "}
beziehungsweise <code>scripts/backup.sh</code> mit <code>pg_dump</code>.
</span>
</p>
</section>
{/* ── Benutzer ────────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
+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;
}
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. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
+89
View File
@@ -0,0 +1,89 @@
import { z } from "zod";
/**
* Austauschformat fuer Projekte.
*
* Bewusst schlank und ohne Geheimnisse:
* - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
* Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
* Exportdatei landet sonst schnell in einem Download-Ordner oder einem
* Cloud-Abgleich.
* - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf;
* ein Abgleich nach dem Import holt sie zurueck.
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
* wurde.
*/
export const EXPORT_KIND = "nebula.projects";
export const EXPORT_VERSION = 1;
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
export const exportedNoteSchema = z.object({
title: z.string().max(160).nullable().optional(),
content: z.string().max(20000),
pinned: z.boolean().optional().default(false),
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({
name: z.string().max(80),
provider: z.enum(PROVIDERS),
baseUrl: z.string().max(300),
});
export const exportedProjectSchema = z.object({
name: z.string().min(1).max(120),
slug: z.string().max(60).optional(),
summary: z.string().max(280).nullable().optional(),
description: z.string().max(20000).nullable().optional(),
status: z.enum(STATUS).optional().default("IDEA"),
priority: z.number().int().min(1).max(4).optional().default(2),
progress: z.number().int().min(0).max(100).optional().default(0),
accent: z.string().max(20).optional().default("violet"),
tags: z.array(z.string().max(30)).max(12).optional().default([]),
favorite: z.boolean().optional().default(false),
autoSync: z.boolean().optional().default(true),
repoUrl: z.string().max(500).nullable().optional(),
repoOwner: z.string().max(200).nullable().optional(),
repoName: z.string().max(200).nullable().optional(),
/** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(),
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(),
});
export const exportFileSchema = z.object({
kind: z.literal(EXPORT_KIND),
version: z.number().int().min(1).max(EXPORT_VERSION),
exportedAt: z.string().optional(),
app: z.string().optional(),
projects: z.array(exportedProjectSchema).min(1).max(1000),
});
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
export type ExportFile = z.infer<typeof exportFileSchema>;
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
export function exportFileName(prefix: string, date: Date, extension: string): string {
const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
const safe = prefix
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return `${safe || "nebula"}-${stamp}.${extension}`;
}
+182
View File
@@ -0,0 +1,182 @@
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status";
import { formatDate, formatDateTime } from "@/lib/utils";
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & {
notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
};
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
export function toExportedProject(project: ProjectForExport): ExportedProject {
return {
name: project.name,
slug: project.slug,
summary: project.summary,
description: project.description,
status: project.status,
priority: project.priority,
progress: project.progress,
accent: project.accent,
tags: project.tags,
favorite: project.favorite,
autoSync: project.autoSync,
repoUrl: project.repoUrl,
repoOwner: project.repoOwner,
repoName: project.repoName,
connection: project.connection
? {
name: project.connection.name,
provider: project.connection.provider,
baseUrl: project.connection.baseUrl,
}
: null,
notes: project.notes.map((note) => ({
title: note.title,
content: note.content,
pinned: note.pinned,
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(),
};
}
export function buildExportFile(projects: ProjectForExport[]): ExportFile {
return {
kind: EXPORT_KIND,
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
app: APP_NAME,
projects: projects.map(toExportedProject),
};
}
/** Laedt die Projekte eines Kontos im fuer den Export noetigen Umfang. */
export async function loadProjectsForExport(
ownerId: string,
projectId?: string,
): Promise<ProjectForExport[]> {
return prisma.project.findMany({
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
},
orderBy: { name: "asc" },
});
}
// ── Markdown ─────────────────────────────────────────────────
const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub",
GITLAB: "GitLab",
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
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
* Datei auch ohne Netz vollstaendig ist.
*/
export function toMarkdown(
project: ProjectForExport & { snapshot: RepoSnapshot | null },
): string {
const status = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const lines: string[] = [];
lines.push(`# ${project.name}`, "");
if (project.summary) lines.push(`> ${project.summary}`, "");
lines.push("| | |", "| --- | --- |");
lines.push(`| Status | ${status.label} |`);
lines.push(`| Fortschritt | ${project.progress} % |`);
lines.push(`| Prioritaet | ${priority.label} |`);
if (project.tags.length) {
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
}
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
if (project.connection) {
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
}
lines.push("");
if (project.description) {
lines.push("## Beschreibung", "", project.description, "");
}
const snapshot = project.snapshot;
if (snapshot) {
lines.push("## Stand des Repositories", "");
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
lines.push("| | |", "| --- | --- |");
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
lines.push(`| Branches | ${snapshot.branchCount} |`);
lines.push(`| Tags | ${snapshot.tagCount} |`);
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
if (snapshot.lastCommitSha) {
lines.push(
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
`${snapshot.lastCommitMessage ?? ""} |`,
);
}
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) {
lines.push("## Notizen", "");
for (const note of project.notes) {
const pin = note.pinned ? " 📌" : "";
lines.push(`### ${note.title ?? "Ohne Titel"}${pin}`, "");
lines.push(note.content, "");
lines.push(`<sub>${formatDateTime(note.createdAt)}</sub>`, "");
}
}
lines.push("---", "");
lines.push(`<sub>Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.</sub>`, "");
return lines.join("\n");
}
+106 -5
View File
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
CiInfo,
CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
TicketInfo,
} from "./types";
import { bucketCommitActivity } from "./types";
/**
* 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 [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<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<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) => ({
@@ -141,6 +164,38 @@ export class GiteaClient implements GitClient {
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 {
fullName: repo.full_name,
description: repo.description ?? null,
@@ -155,13 +210,31 @@ export class GiteaClient implements GitClient {
pushedAt: toIso(repo.updated_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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 ──────────────────────────────────────────────────
interface GiteaRepo {
@@ -220,6 +293,34 @@ interface GiteaRelease {
draft: boolean;
}
interface GiteaLabel {
name: string;
}
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 type {
BranchInfo,
CiInfo,
CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,8 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
TicketInfo,
} from "./types";
import { GitApiError } from "./types";
import { GitApiError, bucketCommitActivity } from "./types";
/**
* 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 [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<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<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) => ({
@@ -131,10 +145,43 @@ export class GitHubClient implements GitClient {
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.
const openPullRequests = pulls.length;
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 {
fullName: repo.full_name,
description: repo.description ?? null,
@@ -149,13 +196,36 @@ export class GitHubClient implements GitClient {
pushedAt: toIso(repo.pushed_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
// Angezeigt werden die letzten 15; die uebrigen dienten der Kurve.
commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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) ──────────
interface GitHubRepo {
@@ -206,6 +276,36 @@ interface GitHubRelease {
draft: boolean;
}
interface GitHubLabel {
name: string;
}
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 type {
BranchInfo,
CiInfo,
CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
TicketInfo,
} from "./types";
import { bucketCommitActivity } from "./types";
/**
* 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 [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<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<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,
}),
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) => ({
@@ -128,6 +142,37 @@ export class GitLabClient implements GitClient {
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 {
fullName: project.path_with_namespace,
description: project.description ?? null,
@@ -144,13 +189,33 @@ export class GitLabClient implements GitClient {
pushedAt: toIso(project.last_activity_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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 ──────────────────────────────────────────────────
interface GitLabProject {
@@ -205,5 +270,29 @@ interface GitLabRelease {
}
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;
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;
}
/** 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 {
fullName: string;
description: string | null;
@@ -67,6 +90,53 @@ export interface RepoSnapshotData extends RepoOverview {
commits: CommitInfo[];
releases: ReleaseInfo[];
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 {
+8 -2
View File
@@ -49,7 +49,10 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect },
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>()({
@@ -58,5 +61,8 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
},
_count: { select: { notes: true } },
tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }],
},
_count: { select: { notes: true, tasks: true } },
});
+10 -3
View File
@@ -1,8 +1,14 @@
import { NextResponse } from "next/server";
/** Groesste akzeptierte JSON-Nutzlast. */
/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */
const MAX_BODY_BYTES = 256 * 1024;
/**
* Groessere Obergrenze fuer den Import: eine Exportdatei mit vielen Notizen
* sprengt 256 KB schnell.
*/
export const IMPORT_MAX_BODY_BYTES = 8 * 1024 * 1024;
export function jsonError(
message: string,
status: number,
@@ -34,6 +40,7 @@ export type BodyResult<T> =
*/
export async function readJsonBody<T = unknown>(
request: Request,
maxBytes: number = MAX_BODY_BYTES,
): Promise<BodyResult<T>> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
@@ -44,7 +51,7 @@ export async function readJsonBody<T = unknown>(
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
@@ -55,7 +62,7 @@ export async function readJsonBody<T = unknown>(
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
}
if (raw.length > MAX_BODY_BYTES) {
if (raw.length > maxBytes) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
+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);
});
});
+8
View File
@@ -117,6 +117,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
commits: data.commits as unknown as object,
releases: data.releases 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,
fetchedAt: new Date(),
};
+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$/);
});
});
+37
View File
@@ -23,10 +23,47 @@ export const projectCreateSchema = z.object({
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
/** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
progressFromTasks: z.boolean().default(false),
});
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({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
+35 -5
View File
@@ -1,5 +1,20 @@
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types";
import type {
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. */
export interface ConnectionSafe {
@@ -23,14 +38,17 @@ export interface ConnectionWithCount extends ConnectionSafe {
export interface ProjectListItem extends Project {
connection: ConnectionSafe | 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 {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
notes: Note[];
_count: { notes: number };
tasks: Task[];
_count: { notes: number; tasks: number };
}
export interface RepoListEntry {
@@ -44,7 +62,19 @@ export interface RepoListEntry {
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. */
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)),
},
},
});