19 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

Im Browser gegen vercel/swr geprueft, dazu ein erfundenes README fuer die
relativen Links, die swr nicht hergibt: aufgeloeste Pfade, unveraenderte
absolute Links, Anker bleiben Anker, kein img im Dokument und kein a im a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 e97bd43b23 feat(ui): README auf der Projektseite anzeigen
Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00
florian.wildeandClaude Opus 5 01884f4747 test: wiederkehrende Aufgaben abdecken, Doku, Version 1.3.0
CI / pruefen (push) Has been cancelled
Sieben Tests fuer die Terminlogik: Rechnen vom Faelligkeitsdatum,
Vorruecken bei langem Liegenbleiben, der Monatsuebergang ueber kurze
Monate, und dass die Schleife auch bei einem Datum von 1970 endet.

Dazu ein Ende-zu-Ende-Test ueber beide Wege nach ERLEDIGT und den Nachweis,
dass ein erneutes Speichern keine zweite Fassung erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 04a07bd36c fix(ui): Kontrast gedaempfter Texte auf WCAG AA bringen
Eine Durchsicht mit axe-core ueber zehn Seitenzustaende - Dialoge einzeln,
sie stehen geschlossen nicht im DOM - fand 26 Verstoesse, alle derselben
Art: slate-500 kommt auf dem sehr dunklen Untergrund nur auf 3,8 zu 1,
slate-600 sogar auf 2,5. Verlangt sind 4,5 fuer normalen Text, und beide
Stufen werden reichlich fuer Hinweise und Nebenangaben benutzt.

Statt an hundert Stellen die Klasse zu tauschen, sind die beiden Stufen im
Theme aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar, der Rest des
Entwurfs unangetastet. Geprueft gegen alle drei vorkommenden Untergruende;
der schlechteste Wert zaehlt.

Damit stimmt auch, was FEATURES.md ueber Barrierefreiheit behauptet - bisher
stand die Behauptung ungeprueft da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 a3093dc68a feat(tasks): wiederkehrende Aufgaben
Taeglich, woechentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe
nach ERLEDIGT, entsteht die naechste von selbst. Die erledigte bleibt
stehen - sonst ginge die Historie verloren.

Gerechnet wird vom Faelligkeitsdatum, nicht vom Abhaken: eine woechentliche
Aufgabe bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch
erledigt wurde. Lange Liegengebliebenes rutscht so weit vor, bis der Termin
in der Zukunft liegt; eine Aufgabe, die schon bei ihrer Entstehung
ueberfaellig ist, hilft niemandem.

Beim Monatsintervall bleibt der Tag im Monat erhalten. setMonth allein
rollt ueber: der 31. Januar wuerde zum 3. Maerz statt zum 28. Februar.

Ausgeloest wird an beiden Stellen, an denen eine Aufgabe erledigt werden
kann - im Dialog und durch Ziehen ins Brett. Nur der Uebergang zaehlt: eine
bereits erledigte Aufgabe erneut zu speichern erzeugt keine zweite Fassung.

Bewusst grob gehalten. Feinere Regeln wie "jeden zweiten Dienstag im Monat"
braucht ein Projektdashboard nicht, und sie waeren ohne Kalenderansicht
kaum nachvollziehbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:39 +02:00
florian.wildeandClaude Opus 5 52cfb075e3 test: Heatmap und Vorlagen abdecken, Doku, Version 1.2.0
CI / pruefen (push) Has been cancelled
Elf Tests fuer die Seitenlogik des Kalenders: Deckel, Abbruch bei
unvollstaendiger Seite, Commits mit Datum in der Zukunft, und dass der
Zeitraum beim aeltesten bekannten Commit beginnt statt vor einem Jahr.

Dazu ein Ende-zu-Ende-Test fuer Vorlagen: sichern, anwenden, Datentrennung
gegen ein zweites Konto, und dass die Vorlage das Loeschen ihres
Ursprungsprojekts ueberlebt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 57c50cfb7f feat(projects): Projektvorlagen
Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage
sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim
Anlegen steht sie oben im Dialog zur Auswahl.

Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben
sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste
waere geraten.

Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage
beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories
nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt,
waere eine Falle.

Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie
vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen.
Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich
nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht
mitgeschleppt bekommen.

templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus
einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 ca6181b58f feat(git): Commit-Heatmap ueber ein Jahr
Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und
Datum im Tooltip.

Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die
Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen
Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits
seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten
Repositories und wenig genug, um kein fremdes Kontingent zu sprengen.

Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen
die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt
es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht
gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler
hatte die Verlaufslinie schon einmal.

Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an
einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares,
der Abgleich laeuft aber im Zweifel stuendlich.

Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als
"keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap
einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag
ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den
Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202
antwortet.

Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus
der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf
den Deckel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:19 +02:00
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

Vier neue Ende-zu-Ende-Tests: Reihenfolge und Mehrfachauswahl ueber die API,
Mehrfachauswahl in der Oberflaeche, der Aenderungsverlauf und das Manifest
samt Symbolen ohne Anmeldung. Damit sind es 21.

Der Test zum Ziehen von Aufgaben war gelegentlich rot, ohne dass etwas kaputt
war: das Neuladen brach den noch laufenden Speicheraufruf ab. Er wartet jetzt
auf die Antwort, statt auf eine feste Pause zu hoffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:44 +02:00
florian.wildeandClaude Opus 5 16e8d31277 feat(ui): Aenderungsverlauf hinter der Versionsnummer
Ein Klick auf die Version in der Fusszeile zeigt, was in den bisherigen
Fassungen dazugekommen ist, was besser wurde und welche Fehler behoben sind.
Kein eigener Menuepunkt: gesucht wird so etwas dort, wo die Version steht.

Die Liste steht in lib/changelog.ts und nicht in einer CHANGELOG.md. Sie
wird in der Anwendung angezeigt, und zwei Quellen liefen prompt auseinander
- wie es der Versionsnummer schon einmal passiert ist, die eine Zeit lang in
der Fusszeile und in der package.json verschiedene Werte zeigte. Ein Test
haelt den obersten Eintrag und die package.json seitdem zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 d97b6f650a feat(pwa): als App installierbar
Web-Manifest mit eigenen Symbolen; auf dem Handy laesst sich Nebula zum
Startbildschirm hinzufuegen und laeuft dann ohne Adressleiste.

Das Manifest entsteht wie die uebrigen Metadaten aus einer Funktion, nicht
aus einer Konstanten: nur so wird APP_NAME zur Laufzeit gelesen und das
fertige Docker-Image bleibt umbenennbar.

Manifest und Symbole muessen an der Zugangsschranke vorbei - der Browser
holt sie, bevor sich jemand angemeldet hat. Sonst landeten sie auf der
Anmeldeseite und die Anwendung waere nicht installierbar.

Die maskierbare Fassung hat mehr Rand: Android schneidet Symbole je nach
Geraet rund, eckig oder als Tropfen zu. Die PNGs sind aus dem vorhandenen
SVG mit Chrome erzeugt - das spart eine Bildbibliothek als Abhaengigkeit.

Beim Ergaenzen des Apple-Symbols ist mir aufgefallen, dass ein eigener
icons-Eintrag den Verweis ersetzt, den Next aus app/icon.svg von selbst
erzeugt: die Seite hatte danach gar kein Symbol mehr und der Browser fiel
auf /favicon.ico zurueck, das es hier nicht gibt. Beide Eintraege stehen
jetzt ausdruecklich da, ein Test prueft den Verweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 49e253f70b feat(board): Mehrfachauswahl fuer Status, Tags und Loeschen
Kaestchen auf jeder Karte, darunter eine Leiste am unteren Rand. Fest
verankert, weil sie bei einer langen Liste oben laengst aus dem Blick
gescrollt waere, waehrend man noch auswaehlt.

Ein eigener Endpunkt statt vieler einzelner PATCHes: eine Transaktion statt
zwanzig Rundgaengen, und der Verlauf bekommt je Projekt genau einen Eintrag
statt eines Schwalls.

Was der Filter ausblendet, faellt aus der Auswahl - sonst traefe eine
Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. Fremde IDs
sammelt der Server wegen der Besitzerbedingung erst gar nicht ein und meldet
sie als uebergangen.

Das Kaestchen ist gedaempft, aber immer sichtbar. Erst beim Ueberfahren
einzublenden waere auf einem Touchgeraet nicht erreichbar - und wer es nie
sieht, weiss nicht, dass es die Auswahl gibt.

Ansicht, Sortierung und Archivschalter ueberdauern nun das Neuladen. Wer sein
Brett von Hand anordnet, will es beim naechsten Aufruf auch sehen. Der
Suchbegriff bleibt aussen vor: eine Suche von gestern wiederherzustellen
waere eher verwirrend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 5253b0a317 test: mehrere Repositories abdecken, Doku, Version 1.0.0
CI / pruefen (push) Has been cancelled
Drei neue Ende-zu-Ende-Tests: das Verwalten mehrerer Repositories samt
Datentrennung, das Lesen von Exportdateien der Version 1, und die
Uebernahme ohne verknuepftes Repository.

Dabei fiel auf, dass der Uebernahme-Endpunkt ohne Repository mit 404
antwortete, obwohl gar keine Repository-ID angefragt war. Wer nichts angibt,
hat schlicht noch keines verknuepft - das ist kein "nicht gefunden", sondern
409 mit dem Hinweis, eines zu hinterlegen.

Version 1.0.0: der Funktionsumfang ist beisammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 b882c870c3 feat(ui): Repositories im Git-Panel verwalten
Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines
gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur
Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich.

Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben
Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur
das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es
eigentlich um das Projekt geht.

Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die
Karte zeigt "+2 Repos" und im Titel, welche es sind.

repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern:
lib/repositories zieht Prisma und damit node:-Module nach, die in einer
Client-Komponente nicht landen duerfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 a843f08211 feat(git): mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber
eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene
Tabelle, der Snapshot haengt jetzt am Repository.

Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden
kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem*
erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt.

Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt
verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand
bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten
Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem
seiner Teile gearbeitet wird.

Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst
zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie
sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander -
ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das,
worauf Rate-Limits reagieren.

Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und
Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft
gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe
Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere
Aufgaben ohne Herkunft bleiben erlaubt.

Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das
fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo,
und der Import muss von der neuen Tabelle nichts wissen.

Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt
lesbar: dort stand genau ein Repository flach am Projekt.

Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den
Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht.
Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung,
Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo
bekommen keine leere Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:23:53 +02:00
florian.wildeandClaude Opus 5 2bff6faf3d feat(auth): Zwei-Faktor-Anmeldung per TOTP
Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und
zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht.

Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer
Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und
der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht
nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen
waere unverhaeltnismaessig.

Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein
bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte
und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus.

Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen
Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine
Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung
durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf
Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe
durchprobiert werden.

Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind
zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein
langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und
kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von
Hand abgeschrieben werden.

Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter
Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen
entfernen koennen.

Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit
Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten
mit falschem und richtigem Passwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:37 +02:00
florian.wildeandClaude Opus 5 af562584ad feat(projects): Aktivitaetsverlauf je Projekt
Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite
beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits.

Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status
und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder
zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe
alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor.

Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel
stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als
beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits
hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den
Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf.

Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag
soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst
nicht mehr gibt.

Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang
stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was
in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo
nicht gibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:19 +02:00
112 changed files with 8684 additions and 478 deletions
+38 -2
View File
@@ -69,15 +69,31 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
`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
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow"
npx prisma migrate diff --from-migrations prisma/migrations \
--to-schema-datamodel prisma/schema.prisma \
--shadow-database-url postgresql://nebula:nebula@localhost:5432/nebula_shadow \
--script --output prisma/migrations/<stempel>_<name>/migration.sql
npx prisma migrate deploy
```
**`--from-migrations`, nicht `--from-schema-datasource`.** Letzteres
vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
einmal.
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird
bislang in Ruhe gelassen, aber das ist nicht garantiert).
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
Vorgehen wie in `20260726230000_repositories`: neue Tabelle anlegen, Bestand
per `INSERT … SELECT` übernehmen, dann erst die alten Spalten entfernen.
Danach `migrate diff` erneut laufen lassen — kommt „empty migration“ heraus,
stimmt das Ergebnis mit dem Schema überein. Und gegen eine Kopie mit echten
Daten prüfen, nicht nur gegen eine leere Datenbank.
## Prüfen, nicht behaupten
Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`.
@@ -86,6 +102,26 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
sich nativ ab. Das sieht aus wie ein Anwendungsfehler, ist aber keiner:
```bash
cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/
```
`pkill` gibt es in dieser Git-Bash nicht. Prozesse über PowerShell beenden
(`Get-CimInstance Win32_Process … | Stop-Process`) — sonst läuft der alte
Server weiter, hält die Prisma-DLL und blockiert den nächsten Bau.
**Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen
und danach löschen:
+148 -11
View File
@@ -27,6 +27,14 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
- **Zwei-Faktor per TOTP.** Zeitbasiertes Einmalkennwort zusätzlich zum Passwort, für jede
gängige Authenticator-App. Einrichtung über QR-Code oder abgetippten Schlüssel; scharf
wird er erst nach einem bestätigten Code, damit sich niemand aussperrt, der den Scan
nicht hinbekommt. Dazu zehn **Wiederherstellungscodes**, jeder genau einmal gültig —
sie werden nur bei der Ausgabe angezeigt. Abschalten und Neuerzeugen verlangen das
Passwort, nicht bloß eine offene Sitzung.
- Die Passkey-Anmeldung fragt **nicht** nach dem zweiten Faktor: der Passkey ist bereits an
ein Gerät gebunden und damit selbst der zweite Faktor.
- Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich
dann nicht entfernen, solange kein Passwort gesetzt ist.
- Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten.
@@ -35,9 +43,9 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
### 0.3 Mein Konto
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Passkeys verwalten ·
aktive Sitzungen einsehen (Browser, Betriebssystem, IP, letzte Aktivität) · überall
abmelden, wobei das eigene Gerät angemeldet bleibt.
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Zwei-Faktor einrichten und
abschalten · Passkeys verwalten · aktive Sitzungen einsehen (Browser, Betriebssystem, IP,
letzte Aktivität) · überall abmelden, wobei das eigene Gerät angemeldet bleibt.
### 0.4 Benutzerverwaltung (Administratoren)
@@ -130,6 +138,33 @@ Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung.
### Projektvorlagen
Ein Projekt, das sich bewährt hat, lässt sich im Projektkopf über **Als Vorlage sichern**
festhalten: Status, Priorität, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen eines
neuen Projekts steht die Vorlage oben im Dialog zur Auswahl.
Bewusst benutzereigen statt fest eingebaut — welche Tags und welche Startaufgaben sinnvoll
sind, weiß nur, wer damit arbeitet. Eine mitgelieferte Liste wäre geraten.
- **Erledigte Aufgaben kommen nicht mit.** Eine Vorlage beschreibt, was zu tun ist, nicht
was einmal getan wurde.
- **Repositories gehen nicht mit.** Eine Vorlage, die jedes neue Projekt an dasselbe
Repository hängt, wäre eine Falle.
- Die Stammdaten füllt der Dialog **sichtbar** in die Felder, damit sie vor dem Speichern
noch änderbar sind; bereits Getipptes wird nicht überschrieben. Notizen und Aufgaben legt
erst der Server an — im Dialog ließen sie sich nicht darstellen.
- Vorlagen überleben das Löschen ihres Ursprungsprojekts.
### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -139,8 +174,15 @@ erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
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.
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick
@@ -165,6 +207,23 @@ Blockiert · Erledigt**.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht.
### Wiederkehrende Aufgaben
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
erledigte bleibt stehen, sonst ginge die Historie verloren.
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche Aufgabe
bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch erledigt wurde.
- Lange Liegengebliebenes rutscht so weit vor, bis der Termin in der Zukunft liegt — eine
Aufgabe, die schon bei ihrer Entstehung überfällig ist, hilft niemandem.
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
28. Februar, nicht zum 3. März.
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
speichern erzeugt keine zweite Fassung.
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
@@ -215,6 +274,54 @@ statt verschoben.
## 4. Git-Anbindung
### 4.0 Mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus mehreren Repositories — Frontend und Backend etwa, oder
App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
- Im Git-Panel steht eine Leiste mit allen Repositories des Projekts, sobald es mehr als
eines gibt. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich; „Alle" gleicht
sie nacheinander ab — nicht gleichzeitig, denn ein Schwung Anfragen an denselben Anbieter
ist genau das, worauf Rate-Limits reagieren.
- Eine frei wählbare **Bezeichnung** („Frontend") tritt an die Stelle von `owner/name`.
- Das erste Repository ist das **führende**. Wo nur eines dargestellt werden kann — auf der
Karte im Dashboard, im Kanban —, ist es dieses. Die Karte zeigt daneben `+2 Repos`.
Ein Abgleichsfehler an *irgendeinem* Repository erscheint auf der Karte, sonst bliebe er
unbemerkt.
- **Stillstand** bemisst sich am jüngsten Commit über alle Repositories: an einem Vorhaben
wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.
- Benachrichtigungen merken sich den gemeldeten Zustand **je Repository**. Sonst verdeckte
ein rotes Frontend die rote Pipeline des Backends. Der Betreff nennt das Repository nur,
wenn es nicht ohnehin so heißt wie das Projekt.
- Übernommene Issues merken sich, aus welchem Repository sie stammen — Frontend und Backend
haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.0a README-Vorschau
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
READMEs sind eingeklappt, ein Knopf zeigt alles.
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
(Wurzelverzeichnis auflisten, dann die Datei holen).
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
Codeblock stehen bleibt.
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
liefen sie ins Leere.
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
Bereinigung wie Notizen (`rehype-sanitize`).
### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit |
@@ -252,6 +359,7 @@ Firmen-Gitea nebeneinander.
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| **Commit-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten |
@@ -278,9 +386,19 @@ bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung |
| --- | --- |
| Abstand | 15 Minuten bis täglich |
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
| Repositories je Durchlauf | 1–100, Voreinstellung 25 |
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
Gezählt und begrenzt wird je **Repository**, nicht je Projekt: sonst zöge ein Projekt mit
fünf Repos fünfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt.
Die Daten für die **Heatmap** kommen getrennt und höchstens einmal am Tag — an einem
Jahresüberblick ändert sich von Stunde zu Stunde nichts Sichtbares. Bei GitHub genügt dafür
ein Aufruf der Jahresstatistik; GitLab und Gitea kennen keine, dort werden bis zu fünf
Seiten Commits geholt. Reicht das nicht ein Jahr zurück, **beginnt die Heatmap beim
ältesten bekannten Commit** und sagt es dazu — leere Felder für Zeiträume zu zeichnen, über
die nichts bekannt ist, wäre eine Lüge.
Wie der Durchlauf arbeitet:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
@@ -424,10 +542,24 @@ letzten Zustellung stehen an der Karte.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer
Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
- **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
schaltet Animationen ab.
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
zwischen ihnen bleibt sichtbar.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
---
@@ -441,13 +573,18 @@ letzten Zustellung stehen an der Karte.
| `Session` | Serverseitige Sitzungen (nur der Token-Hash) |
| `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler |
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `RecoveryCode` | Wiederherstellungscodes für die Zwei-Faktor-Anmeldung (nur der Hash) |
| `PendingLogin` | Zwischenzustand zwischen Passwort und zweitem Faktor, fünf Minuten gültig |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Repository) |
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus
der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
+13 -1
View File
@@ -324,12 +324,15 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
| `POST` | `/api/auth/mfa` | Zweiter Faktor: Einmalkennwort oder Wiederherstellungscode |
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
| `PATCH` | `/api/account/profile` | Eigenes Profil |
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
| `GET` `POST` `PUT` `DELETE` | `/api/account/totp` | Zwei-Faktor: Zustand, einrichten, aktivieren, abschalten |
| `POST` | `/api/account/totp/recovery` | Neue Wiederherstellungscodes |
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
@@ -338,13 +341,22 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` `POST` | `/api/templates` | Projektvorlagen auflisten / anlegen |
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen |
| `PATCH` | `/api/projects/reorder` | Neue Reihenfolge im Brett |
| `POST` | `/api/projects/bulk` | Status, Tags oder Löschen für mehrere |
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen |
+30 -31
View File
@@ -20,70 +20,64 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
---
## 2 · Danach
### 1.2 Läuft es noch? `M`
### 2.1 Commit-Aktivität als Heatmap `S`
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen.
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.
### 1.3 Persönliche API-Token `M`
### 2.2 Kanban mit freier Reihenfolge `M`
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den
Sitzungen.
---
## 3 · Größere Brocken
## 2 · Größere Brocken
### 3.1 Mehrere Repositories je Projekt `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
und Anpassungen in der Anzeige.
### 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.3 Passwort-Zurücksetzen per E-Mail `M`
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.4 Mehrsprachigkeit `L`
### 2.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst.
---
## 4 · Kleinigkeiten
## 3 · Kleinigkeiten
| Idee | Aufwand | Nutzen |
| --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| 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" |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| 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. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
---
## 5 · Bewusst nicht
## 4 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
Dependabot machen das besser, und es wären viele Aufrufe an fremde
Registries — für eine Zahl, die im Repo selbst schon steht.
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
@@ -111,6 +105,11 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ |
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
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
+374 -1
View File
@@ -191,11 +191,345 @@ test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => {
await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json()
).project;
// Ohne Repo-Stand gibt es nichts zu uebernehmen – und das sagt der Server auch.
// Ohne verknuepftes Repository gibt es nichts zu uebernehmen.
const ohneRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneRepo.status()).toBe(409);
// Mit Repository, aber ohne geholten Stand ebenfalls nicht – der Server
// laedt hier bewusst nichts nach.
await request.patch(`/api/projects/${projekt.id}`, {
data: { repoUrl: "https://github.com/vercel/swr" },
});
const ohneStand = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneStand.status()).toBe(409);
// Nach einem bestimmten, das es nicht gibt, ist dagegen 404 richtig.
const fremdesRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: { repositoryId: "gibt-es-nicht" },
});
expect(fremdesRepo.status()).toBe(404);
});
test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Beim Anlegen setzen die Git-Felder das fuehrende Repository.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Zwei Teile", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
expect(projekt.repositories).toHaveLength(1);
expect(projekt.repositories[0].repoOwner).toBe("vercel");
expect(projekt.repositories[0].repoName).toBe("swr");
// Ein zweites dazu.
const zweites = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Backend", repoUrl: "https://github.com/vercel/next.js" },
});
expect(zweites.status()).toBe(201);
const backendId = (await zweites.json()).repository.id;
// Dasselbe Repo zweimal ist immer ein Versehen.
const doppelt = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { repoUrl: "https://github.com/vercel/next.js" },
});
expect(doppelt.status()).toBe(409);
// Halbe Angaben ergeben kein Repository.
const leer = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Nur ein Name" },
});
expect(leer.status()).toBe(422);
const geladen = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(geladen.repositories).toHaveLength(2);
// Das fuehrende steht vorn.
expect(geladen.repositories[0].repoName).toBe("swr");
expect(geladen.repositories[1].label).toBe("Backend");
// Der Projekt-PATCH bearbeitet weiterhin nur das fuehrende.
await request.patch(`/api/projects/${projekt.id}`, { data: { autoSync: false } });
const danach = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(danach.repositories[0].autoSync).toBe(false);
expect(danach.repositories[1].autoSync).toBe(true);
// Fremde Konten kommen an kein Repository.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.get(`/api/projects/${projekt.id}/repositories`)).status()).toBe(404);
expect((await fremd.patch(`/api/repositories/${backendId}`, { data: {} })).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${backendId}`)).status()).toBe(404);
expect((await fremd.post(`/api/repositories/${backendId}/sync`)).status()).toBe(404);
await fremd.dispose();
// Entfernen laesst das andere stehen.
expect((await request.delete(`/api/repositories/${backendId}`)).ok()).toBeTruthy();
const zumSchluss = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(zumSchluss.repositories).toHaveLength(1);
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Projekt herrichten, das als Vorlage taugt.
const quelle = (
await (
await request.post("/api/projects", {
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
})
).json()
).project;
await request.post(`/api/projects/${quelle.id}/notes`, {
data: { content: "Checkliste vor dem Start." },
});
const erledigt = (
await (
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
).json()
).task;
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
const gesichert = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
});
expect(gesichert.status()).toBe(201);
const vorlage = (await gesichert.json()).template;
expect(vorlage.tags).toEqual(["web", "docker"]);
expect(vorlage.status).toBe("PLANNING");
expect(vorlage.notes).toHaveLength(1);
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
expect(vorlage.tasks).toHaveLength(2);
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
// Derselbe Name ein zweites Mal wird abgewiesen.
const doppelt = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App" },
});
expect(doppelt.status()).toBe(409);
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
const neu = (
await (
await request.post("/api/projects", {
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
})
).json()
).project;
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
expect(geladen.notes).toHaveLength(1);
expect(geladen.tasks).toHaveLength(2);
// Die Reihenfolge der Vorlage bleibt erhalten.
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
"Repo anlegen",
"CI einrichten",
]);
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
expect(geladen.status).toBe("IDEA");
// Der Verlauf haelt die Herkunft fest.
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
// Eine fremde Vorlage laesst sich nicht unterschieben.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
const untergeschoben = await fremd.post("/api/projects", {
data: { name: "Klau", templateId: vorlage.id },
});
expect(untergeschoben.status()).toBe(404);
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
await fremd.dispose();
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
await request.delete(`/api/projects/${quelle.id}`);
const nochDa = (await (await request.get("/api/templates")).json()).templates;
expect(nochDa).toHaveLength(1);
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
@@ -218,6 +552,12 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async (
const datei = JSON.parse(roh);
const vorher = datei.projects.length;
// Version 2: die Repositories stehen als Liste in der Datei.
expect(datei.version).toBe(2);
const mitRepo = datei.projects.find((entry: { name: string }) => entry.name === "Zwei Teile");
expect(mitRepo.repositories).toHaveLength(1);
expect(mitRepo.repositories[0].repoName).toBe("swr");
// Ueberspringen aendert nichts.
const uebersprungen = await request.post("/api/projects/import", {
data: { data: datei, strategy: "skip", dryRun: false },
@@ -239,3 +579,36 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async (
});
expect(muell.status()).toBe(422);
});
test("Dateien der Version 1 bleiben lesbar", async ({ request }) => {
await login(request, ADMIN);
// Damals stand genau ein Repository flach am Projekt. Wer eine solche
// Sicherung liegen hat, soll sie weiterhin einspielen koennen.
const alt = {
kind: "nebula.projects",
version: 1,
projects: [
{
name: "Aus Version eins",
repoUrl: "https://github.com/vercel/swr",
repoOwner: "vercel",
repoName: "swr",
autoSync: false,
},
],
};
const gelesen = await request.post("/api/projects/import", {
data: { data: alt, strategy: "rename", dryRun: false },
});
expect(gelesen.ok()).toBeTruthy();
const projekte = (await (await request.get("/api/projects")).json()).projects;
const uebernommen = projekte.find(
(entry: { name: string }) => entry.name === "Aus Version eins",
);
expect(uebernommen.repositories).toHaveLength(1);
expect(uebernommen.repositories[0].repoOwner).toBe("vercel");
expect(uebernommen.repositories[0].autoSync).toBe(false);
});
+75
View File
@@ -72,9 +72,17 @@ test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert.
await page.reload();
@@ -119,6 +127,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
});
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click();
+348 -6
View File
@@ -1,12 +1,12 @@
{
"name": "nebula",
"version": "0.6.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nebula",
"version": "0.6.0",
"version": "1.2.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -18,6 +18,7 @@
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"nodemailer": "^9.0.3",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
@@ -30,8 +31,11 @@
"@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
@@ -2331,6 +2335,16 @@
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2475,6 +2489,30 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -2499,6 +2537,16 @@
"node": ">=12"
}
},
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -2538,6 +2586,28 @@
}
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
@@ -2650,6 +2720,35 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2707,6 +2806,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -2776,11 +2884,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -2800,6 +2914,12 @@
"fast-check": "^3.23.1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
@@ -2969,6 +3089,19 @@
}
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2984,6 +3117,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -3114,6 +3256,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
@@ -3419,6 +3570,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4457,6 +4620,42 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -4482,6 +4681,15 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -4573,6 +4781,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -4673,6 +4890,23 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -4832,6 +5066,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -4885,6 +5134,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -4970,6 +5225,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4984,6 +5253,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -5457,6 +5738,12 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -5474,6 +5761,61 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "0.9.0",
"version": "1.4.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -30,9 +30,6 @@
"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",
@@ -43,6 +40,7 @@
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"nodemailer": "^9.0.3",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
@@ -55,8 +53,11 @@
"@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT,
"kind" "ActivityKind" NOT NULL,
"summary" TEXT NOT NULL,
"meta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3),
ADD COLUMN "totpSecretCipher" TEXT;
-- CreateTable
CREATE TABLE "RecoveryCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecoveryCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PendingLogin" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PendingLogin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RecoveryCode_codeHash_key" ON "RecoveryCode"("codeHash");
-- CreateIndex
CREATE INDEX "RecoveryCode_userId_idx" ON "RecoveryCode"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PendingLogin_tokenHash_key" ON "PendingLogin"("tokenHash");
-- CreateIndex
CREATE INDEX "PendingLogin_userId_idx" ON "PendingLogin"("userId");
-- CreateIndex
CREATE INDEX "PendingLogin_expiresAt_idx" ON "PendingLogin"("expiresAt");
-- AddForeignKey
ALTER TABLE "RecoveryCode" ADD CONSTRAINT "RecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PendingLogin" ADD CONSTRAINT "PendingLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,113 @@
-- Mehrere Repositories je Projekt.
--
-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende
-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht;
-- der Snapshot haengt danach am Repository statt am Projekt.
--
-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt
-- ersatzlos loeschen und die Snapshots gleich mit.
-- ── 1. Neue Tabelle ─────────────────────────────────────────
CREATE TABLE "Repository" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"label" TEXT,
"repoUrl" TEXT,
"repoOwner" TEXT,
"repoName" TEXT,
"defaultBranch" TEXT,
"connectionId" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"lastSyncedAt" TIMESTAMP(3),
"syncError" TEXT,
"autoSync" BOOLEAN NOT NULL DEFAULT true,
"syncFailureCount" INTEGER NOT NULL DEFAULT 0,
"notifiedSyncError" TEXT,
"notifiedCiState" TEXT,
"notifiedRelease" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Repository_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position");
CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId");
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ── 2. Bestand uebernehmen ──────────────────────────────────
-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine
-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine
-- leere Zeile.
INSERT INTO "Repository" (
"id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", "position", "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", "updatedAt"
)
SELECT
gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", 0, "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", CURRENT_TIMESTAMP
FROM "Project"
WHERE "repoUrl" IS NOT NULL
OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL);
-- ── 3. Snapshots umhaengen ──────────────────────────────────
ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT;
UPDATE "RepoSnapshot" AS s
SET "repositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = s."projectId";
-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn
-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht
-- mehr zuzuordnen und beim naechsten Abgleich neu geholt.
DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL;
ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL;
ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey";
DROP INDEX "RepoSnapshot_projectId_key";
ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId";
CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId");
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey"
FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ── 4. Herkunft uebernommener Aufgaben ──────────────────────
-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos
-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5.
ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT;
UPDATE "Task" AS t
SET "sourceRepositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL;
DROP INDEX "Task_projectId_sourceIssue_key";
CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key"
ON "Task"("projectId", "sourceRepositoryId", "sourceIssue");
-- ── 5. Projekt aufraeumen ───────────────────────────────────
ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey";
DROP INDEX "Project_connectionId_idx";
ALTER TABLE "Project"
DROP COLUMN "repoUrl",
DROP COLUMN "repoOwner",
DROP COLUMN "repoName",
DROP COLUMN "defaultBranch",
DROP COLUMN "connectionId",
DROP COLUMN "lastSyncedAt",
DROP COLUMN "syncError",
DROP COLUMN "autoSync",
DROP COLUMN "syncFailureCount",
DROP COLUMN "notifiedSyncError",
DROP COLUMN "notifiedCiState",
DROP COLUMN "notifiedRelease";
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ProjectTemplate" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"summary" TEXT,
"projectDescription" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"progressFromTasks" BOOLEAN NOT NULL DEFAULT false,
"notes" JSONB NOT NULL DEFAULT '[]',
"tasks" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name");
-- AddForeignKey
ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT,
ADD COLUMN "recurrence" "Recurrence";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
ADD COLUMN "readmeAt" TIMESTAMP(3),
ADD COLUMN "readmeBaseUrl" TEXT,
ADD COLUMN "readmePath" TEXT;
+241 -38
View File
@@ -52,6 +52,35 @@ enum NotificationEvent {
PROJECT_STALE
}
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
/// mitschreiben.
enum ActivityKind {
PROJECT_CREATED
PROJECT_UPDATED
STATUS_CHANGED
NOTE_ADDED
NOTE_UPDATED
NOTE_DELETED
TASK_ADDED
TASK_MOVED
TASK_DELETED
TASKS_IMPORTED
REPO_LINKED
REPO_COMMITS
SYNC_FAILED
}
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
enum Recurrence {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
@@ -93,11 +122,22 @@ model User {
failedLoginCount Int @default(0)
lockedUntil DateTime?
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
sessions Session[]
credentials Credential[]
projects Project[]
connections GitConnection[]
notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -142,6 +182,45 @@ model Credential {
@@index([userId])
}
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
/// Jeder Code gilt genau einmal.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
model PendingLogin {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
tokenHash String @unique
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge {
@@ -218,7 +297,7 @@ model GitConnection {
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
projects Project[]
repositories Repository[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -247,24 +326,9 @@ model Project {
accent String @default("violet")
tags String[] @default([])
favorite Boolean @default(false)
// ── Git-Anbindung ──────────────────────────────────────────
repoUrl String?
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
repoOwner String?
repoName String?
defaultBranch String?
connectionId String?
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
lastSyncedAt DateTime?
syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
/// einem Repo, dessen Token staendig Fehler wirft.
autoSync Boolean @default(true)
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
position Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
@@ -275,28 +339,77 @@ model Project {
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime?
/// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht
/// ueber ein einzelnes Repository.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
notes Note[]
tasks Task[]
activities Activity[]
repositories Repository[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([connectionId])
@@index([updatedAt])
@@index([ownerId])
@@index([ownerId, status, position])
}
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
/// Frontend und Backend etwa –, gehoert aber in eine Karte.
///
/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen
/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist.
model Repository {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name
/// steht fuer sich.
label String?
repoUrl String?
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
repoOwner String?
repoName String?
defaultBranch String?
connectionId String?
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
/// Reihenfolge in der Anzeige.
position Int @default(0)
lastSyncedAt DateTime?
syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
/// einem Repo, dessen Token staendig Fehler wirft.
autoSync Boolean @default(true)
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
snapshot RepoSnapshot?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, position])
@@index([connectionId])
}
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
@@ -347,20 +460,93 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer und Link merken, damit
/// dasselbe Issue nicht zweimal uebernommen wird.
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
/// bleibt stehen – sonst ginge die Historie verloren.
recurrence Recurrence?
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
/// Nachfolgerin nicht mitnimmt.
recurredFrom String?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int?
sourceUrl String?
/// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und
/// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
sourceRepositoryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
@@unique([projectId, sourceIssue])
@@unique([projectId, sourceRepositoryId, sourceIssue])
@@index([projectId, status, position])
}
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
///
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
/// mitgelieferte Liste waere geraten.
model ProjectTemplate {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
name String
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
description String?
// ── Was die Vorlage in das neue Projekt schreibt ───────────
summary String?
projectDescription String?
status ProjectStatus @default(IDEA)
priority Int @default(2)
accent String @default("violet")
tags String[] @default([])
progressFromTasks Boolean @default(false)
/// [{ title, content, pinned }]
notes Json @default("[]")
/// [{ title, description, status, labels }]
tasks Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([ownerId])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
/// automatischen Abgleich.
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
kind ActivityKind
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
summary String
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
meta Json?
createdAt DateTime @default(now())
/// Absteigend gelesen, immer je Projekt.
@@index([projectId, createdAt])
}
/// Freie Notizen zu einem Projekt.
model Note {
id String @id @default(cuid())
@@ -376,12 +562,12 @@ model Note {
@@index([pinned])
}
/// Zwischengespeicherter Stand des verknuepften Repositories. Wird beim
/// Sync ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
/// Zwischengespeicherter Stand eines Repositories. Wird beim Sync
/// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
model RepoSnapshot {
id String @id @default(cuid())
projectId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
repositoryId String @unique
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
fullName String?
description String?
@@ -419,6 +605,23 @@ model RepoSnapshot {
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
/// und deshalb hoechstens einmal am Tag geschieht.
commitCalendar Json @default("{}")
commitCalendarAt DateTime?
/// Das README des Repositories, roh als Markdown. Wie der Kalender
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
/// Abgleich laeuft im Zweifel stuendlich.
readme String?
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
readmePath String?
/// Wogegen relative Links im README aufzuloesen sind.
readmeBaseUrl String?
readmeAt DateTime?
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+11 -1
View File
@@ -18,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AccountPage() {
const user = await requireUserPage();
const [passkeys, sessions, targets] = await Promise.all([
const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
prisma.credential.findMany({
where: { userId: user.id },
select: {
@@ -58,6 +58,11 @@ export default async function AccountPage() {
},
orderBy: { name: "asc" },
}),
prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { totpEnabledAt: true },
}),
prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }),
]);
const data: AccountData = {
@@ -69,6 +74,11 @@ export default async function AccountPage() {
passkeys,
sessions,
passkeysAvailable: WEBAUTHN_AVAILABLE,
totp: {
enabled: Boolean(konto.totpEnabledAt),
enabledAt: konto.totpEnabledAt,
recoveryCodesLeft: offeneCodes,
},
};
return (
+3 -1
View File
@@ -27,7 +27,9 @@ export default async function AdminPage() {
}),
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
prisma.project.count({
// Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
// der abgeglichen und begrenzt wird.
prisma.repository.count({
where: {
autoSync: true,
OR: [
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { issueRecoveryCodes } from "@/lib/auth/recovery";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
export const dynamic = "force-dynamic";
const schema = z.object({ password: z.string().min(1).max(200) });
/**
* Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren
* Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten.
*
* Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig
* zum zweiten Faktor.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) {
return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
}
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) });
}
+167
View File
@@ -0,0 +1,167 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Zwei-Faktor-Anmeldung per Einmalkennwort.
*
* Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und
* zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf.
* Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den
* QR-Code nie scannt, sperrt sich so nicht aus.
*/
/** Zustand fuer die Anzeige. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true },
});
return NextResponse.json({
enabled: Boolean(user.totpEnabledAt),
enabledAt: user.totpEnabledAt,
recoveryCodesLeft: user.totpEnabledAt
? await countUnusedRecoveryCodes(auth.user.id)
: 0,
});
}
/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */
export async function POST() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true, username: true },
});
if (user.totpEnabledAt) {
return jsonError(
"Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.",
409,
);
}
// Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus
// einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne
// dass hier bekannt waere, in welcher.
const secret = generateTotpSecret();
await prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: encryptSecret(secret) },
});
const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME });
return NextResponse.json({
// Das Bild als data:-URI, damit die Content-Security-Policy nichts
// nachladen muss. img-src erlaubt data: bereits.
qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }),
uri,
/** Zum Abtippen, falls die Kamera nicht mitspielt. */
secret: groupSecret(secret),
});
}
const enableSchema = z.object({ code: z.string().min(6).max(10) });
/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */
export async function PUT(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = enableSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpSecretCipher: true, totpEnabledAt: true },
});
if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409);
const secret = tryDecryptSecret(user.totpSecretCipher);
if (!secret) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
}
if (!verifyTotp(secret, parsed.data.code)) {
return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401,
);
}
await prisma.user.update({
where: { id: auth.user.id },
data: { totpEnabledAt: new Date() },
});
return NextResponse.json({
ok: true,
// Einmalige Ausgabe: gespeichert wird nur ihr Hash.
recoveryCodes: await issueRecoveryCodes(auth.user.id),
});
}
const disableSchema = z.object({ password: z.string().min(1).max(200) });
/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */
export async function DELETE(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = disableSchema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
// Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor
// nicht im Vorbeigehen entfernen koennen.
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
await prisma.$transaction([
prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null },
}),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
]);
return NextResponse.json({ ok: true });
}
+13
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
@@ -68,6 +69,18 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401);
}
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
// noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist.
if (user.totpEnabledAt) {
// Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist:
// sonst liesse sich ueber wiederholte Passworteingaben die Sperre
// umgehen, die falsche Passwoerter aufbaut.
const token = await createPendingLogin(user.id);
const response = NextResponse.json({ ok: false, mfaRequired: true });
setMfaCookie(response, token);
return response;
}
await noteSuccessfulLogin(user.id);
resetRateLimit(`login:user:${username}`);
resetRateLimit(`login:ip:${ip}`);
+98
View File
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
clearMfaCookie,
discardPendingLogin,
noteMfaFailure,
readPendingLogin,
} from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/**
* Der zweite Schritt der Anmeldung: Einmalkennwort oder
* Wiederherstellungscode.
*
* Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne
* diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts.
*/
const schema = z.object({
code: z.string().min(1).max(40),
});
const GENERIC = "Der Code stimmt nicht.";
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const pending = await readPendingLogin();
if (!pending) {
const response = jsonError(
"Die Anmeldung ist abgelaufen. Bitte melde dich neu an.",
401,
);
clearMfaCookie(response);
return response;
}
if (!pending.user.isActive) {
await discardPendingLogin(pending.id);
const response = jsonError("Dieses Konto ist deaktiviert.", 403);
clearMfaCookie(response);
return response;
}
const eingabe = parsed.data.code.trim();
// Sechs Ziffern sind ein Einmalkennwort, alles andere ein
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe)
: await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id);
if (!weiterMoeglich) {
const response = jsonError(
"Zu viele Fehlversuche. Bitte melde dich neu an.",
429,
);
clearMfaCookie(response);
return response;
}
return jsonError(GENERIC, 401);
}
await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id);
const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: pending.user.id, username: pending.user.username, role: pending.user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
clearMfaCookie(response);
return response;
}
+1 -1
View File
@@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string }> };
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: true } },
_count: { select: { repositories: true } },
};
export async function PATCH(request: Request, { params }: Params) {
+1 -1
View File
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: true } },
_count: { select: { repositories: true } },
};
export async function GET() {
+28
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -44,6 +45,18 @@ export async function PATCH(request: Request, { params }: Params) {
},
});
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
await recordActivity({
projectId: note.projectId,
userId: auth.user.id,
kind: "NOTE_UPDATED",
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
meta: { noteId: id },
});
}
return NextResponse.json({ note: updated });
}
@@ -53,10 +66,25 @@ export async function DELETE(_request: Request, { params }: Params) {
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await prisma.note.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: { projectId: true, title: true, content: true },
});
const result = await prisma.note.deleteMany({
where: { id, project: { ownerId: auth.user.id } },
});
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
if (vorher) {
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "NOTE_DELETED",
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
});
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { ACTIVITY_LIMIT } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
export async function GET(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
const roh = new URL(request.url).searchParams.get("limit");
const angefragt = roh === null ? Number.NaN : Number(roh);
const take = Number.isFinite(angefragt)
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
: 30;
const activities = await prisma.activity.findMany({
where: { projectId: id },
orderBy: { createdAt: "desc" },
take,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
});
return NextResponse.json({ activities });
}
+11 -5
View File
@@ -1,7 +1,7 @@
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 { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
@@ -24,10 +24,16 @@ export async function GET(request: Request, { params }: Params) {
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,
...exportInclude,
// Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
// die Datei auch ohne Netz vollstaendig ist.
repositories: {
include: {
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
+9
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -64,5 +65,13 @@ export async function POST(request: Request, { params }: Params) {
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "NOTE_ADDED",
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
meta: { noteId: note.id },
});
return NextResponse.json({ note }, { status: 201 });
}
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import {
completeRepoInput,
namesRepository,
nextRepoPosition,
repoTitle,
} from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
const MAX_REPOS = 20;
const include = {
connection: { select: connectionSafeSelect },
snapshot: true,
} as const;
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const repositories = await prisma.repository.findMany({
where: { projectId: id },
include,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ repositories });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = repositoryCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (!namesRepository(eingabe)) {
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
}
// Eine fremde Verbindung darf nicht untergeschoben werden.
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
if (vorhanden >= MAX_REPOS) {
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
}
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
if (eingabe.repoOwner && eingabe.repoName) {
const doppelt = await prisma.repository.findFirst({
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
select: { id: true },
});
if (doppelt) {
return jsonError(
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
409,
);
}
}
const repository = await prisma.repository.create({
data: {
projectId: id,
label: eingabe.label ?? null,
repoUrl: eingabe.repoUrl ?? null,
repoOwner: eingabe.repoOwner ?? null,
repoName: eingabe.repoName ?? null,
connectionId: eingabe.connectionId ?? null,
autoSync: eingabe.autoSync ?? true,
position: await nextRepoPosition(id),
},
include,
});
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
meta: { repositoryId: repository.id },
});
return NextResponse.json({ repository }, { status: 201 });
}
+84 -16
View File
@@ -1,8 +1,15 @@
import type { ProjectStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
import {
nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
@@ -75,29 +82,34 @@ export async function PATCH(request: Request, { params }: Params) {
"accent",
"tags",
"favorite",
"autoSync",
"progressFromTasks",
"connectionId",
] as const) {
if (data[key] !== undefined) update[key] = data[key];
}
if (data.repoUrl !== undefined) {
update.repoUrl = data.repoUrl;
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null;
if (parsedUrl) {
update.repoOwner = data.repoOwner ?? parsedUrl.owner;
update.repoName = data.repoName ?? parsedUrl.name;
} else if (!data.repoUrl) {
update.repoOwner = null;
update.repoName = null;
}
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
// angekommen ist.
if (data.status !== undefined && data.status !== existing.status) {
update.position = await nextProjectPosition(auth.user.id, data.status);
}
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName;
await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
const repoFelder = {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
};
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) {
@@ -112,6 +124,62 @@ export async function PATCH(request: Request, { params }: Params) {
return NextResponse.json({ project });
}
/**
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
* Farbe den Verlauf.
*/
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus },
update: Record<string, unknown>,
repo: RepoInput | null,
) {
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
const entries: Parameters<typeof recordActivities>[0] = [];
if (update.status !== undefined && update.status !== before.status) {
const to = update.status as ProjectStatus;
entries.push({
projectId,
userId,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, to),
meta: { from: before.status, to },
});
}
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
entries.push({
projectId,
userId,
kind: "REPO_LINKED",
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
meta: { repoUrl: repo.repoUrl ?? null },
});
}
if (repo?.autoSync !== undefined) rest.push("autoSync");
if (repo?.connectionId !== undefined) rest.push("connectionId");
const felder = describeFields(rest);
if (felder) {
entries.push({
projectId,
userId,
kind: "PROJECT_UPDATED",
summary: felder,
meta: { fields: rest },
});
}
await recordActivities(entries);
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { repoTitle } from "@/lib/repositories";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -14,6 +16,8 @@ type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
numbers: z.array(z.number().int()).max(200).optional(),
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
@@ -48,7 +52,23 @@ export async function POST(request: Request, { params }: Params) {
);
}
const snapshot = await prisma.repoSnapshot.findUnique({ where: { projectId: id } });
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
const repository = await prisma.repository.findFirst({
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { snapshot: true },
});
if (!repository) {
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
return parsed.data.repositoryId
? jsonError("Repository nicht gefunden", 404)
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
}
const snapshot = repository.snapshot;
if (!snapshot) {
return jsonError(
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
@@ -67,7 +87,11 @@ export async function POST(request: Request, { params }: Params) {
}
const vorhanden = await prisma.task.findMany({
where: { projectId: id, sourceIssue: { in: auswahl.map((issue) => issue.number) } },
where: {
projectId: id,
sourceRepositoryId: repository.id,
sourceIssue: { in: auswahl.map((issue) => issue.number) },
},
select: { sourceIssue: true },
});
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
@@ -89,6 +113,7 @@ export async function POST(request: Request, { params }: Params) {
labels: issue.labels.slice(0, 8),
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
}),
);
@@ -97,6 +122,21 @@ export async function POST(request: Request, { params }: Params) {
if (angelegt.length > 0) {
await refreshDerivedProgress(id);
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASKS_IMPORTED",
summary:
angelegt.length === 1
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
meta: {
repositoryId: repository.id,
numbers: angelegt.map((task) => task.sourceIssue),
},
});
}
return NextResponse.json({
@@ -3,7 +3,7 @@ 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 { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
const frischErledigt: string[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) {
await prisma.$transaction(updates);
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
for (const taskId of frischErledigt) {
await spawnRecurrence(taskId);
}
await refreshDerivedProgress(id);
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -45,7 +46,7 @@ export async function POST(request: Request, { params }: Params) {
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const { title, description, status, dueDate, labels, recurrence } = parsed.data;
const task = await prisma.task.create({
data: {
@@ -57,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null,
},
});
@@ -65,5 +67,13 @@ export async function POST(request: Request, { params }: Params) {
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 });
}
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+53 -17
View File
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects";
import {
exportFileSchema,
repositoriesOf,
type ExportedRepository,
} from "@/lib/export/format";
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import {
IMPORT_MAX_BODY_BYTES,
@@ -84,7 +89,7 @@ export async function POST(request: Request) {
select: { id: true, name: true, provider: true, baseUrl: true },
});
function matchConnection(described: ExportedProject["connection"]) {
function matchConnection(described: ExportedRepository["connection"]) {
if (!described) return null;
const byName = connections.find(
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
@@ -104,12 +109,26 @@ export async function POST(request: Request) {
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";
// Version 1 kannte ein Repository je Projekt und schrieb es flach; seit
// Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied.
const eingehendeRepos = repositoriesOf(incoming);
const repos = eingehendeRepos.map((repo, index) => ({
label: repo.label ?? null,
repoUrl: repo.repoUrl ?? null,
repoOwner: repo.repoOwner ?? null,
repoName: repo.repoName ?? null,
autoSync: repo.autoSync ?? true,
connectionId: matchConnection(repo.connection ?? null)?.id ?? null,
position: index,
}));
// Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene
// Verbindung fehlt – welche, steht in der Datei.
const beschrieben = eingehendeRepos.filter((repo) => repo.connection);
const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null));
const connectionState: ItemResult["connection"] =
beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched";
const existing = await prisma.project.findFirst({
where: { slug, ownerId: auth.user.id },
@@ -122,7 +141,7 @@ export async function POST(request: Request) {
action: "create",
notes: incoming.notes.length,
connection: connectionState,
connectionName: incoming.connection?.name,
connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name,
};
if (existing && strategy === "skip") {
@@ -145,6 +164,8 @@ export async function POST(request: Request) {
}
const data = {
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
position: await nextProjectPosition(auth.user.id, incoming.status),
name: incoming.name,
summary: incoming.summary ?? null,
description: incoming.description ?? null,
@@ -154,11 +175,6 @@ export async function POST(request: Request) {
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,
};
@@ -194,23 +210,43 @@ export async function POST(request: Request) {
await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
// Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der
// gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten
// Abgleich zurueck.
prisma.repository.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
data: {
...data,
slug,
notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
},
}),
]);
results.push({ ...base, action: "overwrite" });
} else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({
const angelegt = await prisma.project.create({
data: {
...data,
ownerId: auth.user.id,
slug: finalSlug,
notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
},
});
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
// wenigstens fest, woher das Projekt stammt.
await recordActivity({
projectId: angelegt.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary: "Aus einer Exportdatei importiert",
meta: { notes: notes.length, tasks: tasks.length },
});
results.push({
...base,
action: existing ? "rename" : "create",
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Neue Reihenfolge im Brett nach dem Ziehen.
*
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
* anzurichten.
*/
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
// untergeschobene IDs verschieben.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: requestedIds } },
select: { id: true, status: true, updatedAt: true },
});
const ownedIds = new Set(owned.map((project) => project.id));
const vorher = new Map(owned.map((project) => [project.id, project]));
const updates: Prisma.PrismaPromise<unknown>[] = [];
const entries: Parameters<typeof recordActivities>[0] = [];
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
ids.forEach((projectId, index) => {
const before = vorher.get(projectId);
if (!ownedIds.has(projectId) || !before) return;
const gewandert = before.status !== status;
updates.push(
prisma.project.update({
where: { id: projectId },
data: {
status,
position: index,
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
...(gewandert ? {} : { updatedAt: before.updatedAt }),
},
}),
);
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
if (gewandert) {
entries.push({
projectId,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, status),
meta: { from: before.status, to: status },
});
}
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+92 -18
View File
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { projectListInclude, uniqueSlug } from "@/lib/projects";
import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
import { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { statusOf } from "@/lib/status";
import { formatZodError, projectCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -72,18 +74,21 @@ export async function POST(request: Request) {
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
// Owner/Name aus der URL ableiten, falls nicht explizit angegeben.
let repoOwner = data.repoOwner ?? null;
let repoName = data.repoName ?? null;
if (data.repoUrl && (!repoOwner || !repoName)) {
const parsedUrl = parseRepoUrl(data.repoUrl);
if (parsedUrl) {
repoOwner = repoOwner ?? parsedUrl.owner;
repoName = repoName ?? parsedUrl.name;
}
}
// Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die
// Oberflaeche schon in die Felder uebernommen, damit man sie vor dem
// Speichern noch sieht und aendern kann.
const vorlage = data.templateId
? await prisma.projectTemplate.findFirst({
where: { id: data.templateId, ownerId: auth.user.id },
select: { name: true, notes: true, tasks: true },
})
: null;
if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404);
const project = await prisma.project.create({
const notes = templateNotes(vorlage?.notes);
const tasks = templateTasks(vorlage?.tasks);
const angelegt = await prisma.project.create({
data: {
ownerId: auth.user.id,
name: data.name,
@@ -96,14 +101,83 @@ export async function POST(request: Request) {
accent: data.accent,
tags: data.tags,
favorite: data.favorite,
autoSync: data.autoSync,
repoUrl: data.repoUrl ?? null,
repoOwner,
repoName,
connectionId: data.connectionId ?? null,
progressFromTasks: data.progressFromTasks,
// Hinten an die Statusspalte des Bretts.
position: await nextProjectPosition(auth.user.id, data.status),
notes: { create: notes },
tasks: { create: tasks },
},
select: { id: true },
});
// Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen.
await applyLeadingRepo(angelegt.id, {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
});
const project = await prisma.project.findUniqueOrThrow({
where: { id: angelegt.id },
include: projectListInclude,
});
await recordActivity({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary: vorlage
? `Aus Vorlage „${vorlage.name}“ angelegt`
: data.status === "IDEA"
? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: {
status: data.status,
...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}),
},
});
return NextResponse.json({ project }, { status: 201 });
}
/**
* Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript
* unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen
* Eintrag auslassen als am Anlegen scheitern.
*/
function templateNotes(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0)
.slice(0, 50)
.map((entry) => ({
title: typeof entry.title === "string" ? entry.title : null,
content: entry.content as string,
pinned: entry.pinned === true,
}));
}
function templateTasks(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0)
.slice(0, 100)
.map((entry, index) => ({
title: entry.title as string,
description: typeof entry.description === "string" ? entry.description : null,
status: (["TODO", "DOING", "BLOCKED"] as const).includes(
entry.status as "TODO" | "DOING" | "BLOCKED",
)
? (entry.status as "TODO" | "DOING" | "BLOCKED")
: ("TODO" as const),
// Reihenfolge wie in der Vorlage.
position: index,
labels: Array.isArray(entry.labels)
? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8)
: [],
}));
}
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { completeRepoInput, ownedRepository, repoTitle } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, repositoryUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedRepository(id, auth.user.id);
if (!existing) return jsonError("Repository nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = repositoryUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const data: Record<string, unknown> = {};
for (const key of ["label", "repoUrl", "repoOwner", "repoName", "connectionId", "autoSync"] as const) {
if (eingabe[key] !== undefined) data[key] = eingabe[key];
}
if (parsed.data.position !== undefined) data.position = parsed.data.position;
// Zeigt das Repo woandershin, taugt der gesammelte Stand nicht mehr. Ihn
// stehen zu lassen waere schlimmer als ihn zu verlieren: er saehe echt aus.
const zieltWoandershin =
(eingabe.repoOwner !== undefined && eingabe.repoOwner !== existing.repoOwner) ||
(eingabe.repoName !== undefined && eingabe.repoName !== existing.repoName);
if (zieltWoandershin) {
data.lastSyncedAt = null;
data.syncError = null;
data.syncFailureCount = 0;
data.notifiedSyncError = null;
data.notifiedCiState = null;
data.notifiedRelease = null;
await prisma.repoSnapshot.deleteMany({ where: { repositoryId: id } });
}
const repository = await prisma.repository.update({
where: { id },
data,
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ repository });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await ownedRepository(id, auth.user.id);
if (!vorher) return jsonError("Repository nicht gefunden", 404);
await prisma.repository.delete({ where: { id } });
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository entfernt: ${shorten(repoTitle(vorher))}`,
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { ownedRepository } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { syncRepository } from "@/lib/sync";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Gleicht ein einzelnes Repository ab. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
// begrenzt als bei reinen Datenbankzugriffen.
const limited = rateLimit(
`outbound:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
if (!(await ownedRepository(id, auth.user.id))) {
return jsonError("Repository nicht gefunden", 404);
}
const result = await syncRepository(id);
const repository = await prisma.repository.findUnique({
where: { id },
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ ...result, repository }, { status: result.ok ? 200 : 502 });
}
+43 -3
View File
@@ -1,8 +1,15 @@
import type { TaskStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import {
TASK_STATUS_META,
nextPosition,
refreshDerivedProgress,
spawnRecurrence,
} from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -13,7 +20,7 @@ type Params = { params: Promise<{ id: string }> };
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true },
select: { id: true, projectId: true, status: true, title: true },
});
}
@@ -42,6 +49,7 @@ export async function PATCH(request: Request, { params }: Params) {
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.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
@@ -55,9 +63,34 @@ export async function PATCH(request: Request, { params }: Params) {
}
const task = await prisma.task.update({ where: { id }, data: update });
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
const folge =
update.status === "DONE" && existing.status !== "DONE"
? await spawnRecurrence(id)
: null;
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ task });
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
if (update.status !== undefined) {
const nach = update.status as TaskStatus;
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_MOVED",
summary: folge
? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
});
}
return NextResponse.json({ task, next: folge });
}
export async function DELETE(_request: Request, { params }: Params) {
@@ -71,5 +104,12 @@ export async function DELETE(_request: Request, { params }: Params) {
await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId);
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_DELETED",
summary: `Aufgabe gelöscht: ${shorten(existing.title, 50)}`,
});
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.projectTemplate.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Vorlage nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
if (data.name) {
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name, id: { not: id } },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
}
const template = await prisma.projectTemplate.update({ where: { id }, data });
return NextResponse.json({ template });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst.
const result = await prisma.projectTemplate.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateFromProjectSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Aus einem vorhandenen Projekt eine Vorlage machen.
*
* Das ist der Weg, auf dem Vorlagen ueblicherweise entstehen: man richtet ein
* Projekt so ein, wie es passt, und haelt das dann fest. Eine Vorlage von
* Grund auf zu tippen kann man auch – ueber POST /api/templates.
*
* Nicht mitgenommen wird alles Projektbezogene: Name, Slug, Fortschritt,
* Favorit und vor allem die Repositories. Eine Vorlage, die jedes neue
* Projekt an dasselbe Repository haengt, waere eine Falle.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateFromProjectSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { projectId, name, description, withNotes, withTasks } = parsed.data;
const project = await prisma.project.findFirst({
where: { id: projectId, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
},
});
if (!project) return jsonError("Projekt nicht gefunden", 404);
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name,
description: description ?? null,
summary: project.summary,
projectDescription: project.description,
status: project.status,
priority: project.priority,
accent: project.accent,
tags: project.tags,
progressFromTasks: project.progressFromTasks,
notes: withNotes
? project.notes.map((note) => ({
title: note.title,
content: note.content,
pinned: note.pinned,
}))
: [],
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu
// tun ist, nicht was einmal getan wurde.
tasks: withTasks
? project.tasks
.filter((task) => task.status !== "DONE")
.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
labels: task.labels,
}))
: [],
},
});
return NextResponse.json({ template }, { status: 201 });
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Hoechstzahl je Konto. Wer mehr braucht, hat keine Vorlagen mehr, sondern Projekte. */
const MAX_TEMPLATES = 30;
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const templates = await prisma.projectTemplate.findMany({
where: { ownerId: auth.user.id },
orderBy: { name: "asc" },
});
return NextResponse.json({ templates });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const vorhanden = await prisma.projectTemplate.count({ where: { ownerId: auth.user.id } });
if (vorhanden >= MAX_TEMPLATES) {
return jsonError(`Mehr als ${MAX_TEMPLATES} Vorlagen sind nicht vorgesehen.`, 409);
}
const data = parsed.data;
// Der Name ist je Konto eindeutig; ein Duplikat soll eine verstaendliche
// Meldung geben statt eines Datenbankfehlers.
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name: data.name,
description: data.description ?? null,
summary: data.summary ?? null,
projectDescription: data.projectDescription ?? null,
status: data.status,
priority: data.priority,
accent: data.accent,
tags: data.tags,
progressFromTasks: data.progressFromTasks,
notes: data.notes,
tasks: data.tasks,
},
});
return NextResponse.json({ template }, { status: 201 });
}
+24
View File
@@ -124,6 +124,30 @@
}
.md a:hover { color: #ddd6fe; }
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
bekommt sie einen Rahmen und ein Symbol davor. */
.md .md-image {
display: inline-flex;
align-items: center;
gap: 0.3em;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: 0.4rem;
padding: 0.05em 0.45em;
font-size: 0.85em;
color: #8a98b0;
text-decoration: none;
vertical-align: baseline;
}
.md .md-image::before {
content: "▣";
opacity: 0.6;
}
.md a.md-image:hover {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.4);
}
.md strong { color: #f1f5f9; font-weight: 600; }
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function ConnectionsPage() {
const connections = await prisma.gitConnection.findMany({
where: { ownerId: user.id },
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
select: { ...connectionSafeSelect, _count: { select: { repositories: true } } },
orderBy: { name: "asc" },
});
+16
View File
@@ -16,6 +16,22 @@
--color-void-soft: #0a0c1a;
--color-haze: #12142b;
/* ── Gedaempfte Textfarben ──────────────────────────────────
Tailwinds slate-500 und slate-600 kommen auf diesem sehr dunklen
Untergrund nur auf 3,8 bzw. 2,5 zu 1. WCAG AA verlangt 4,5 fuer
normalen Text, und beide werden hier reichlich fuer Hinweise und
Nebenangaben benutzt – axe-core hat das an 26 Stellen beanstandet.
Statt an hundert Stellen die Klasse zu tauschen, werden die beiden
Stufen hier aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar,
der Rest des Entwurfs unangetastet.
Geprueft gegen die drei vorkommenden Untergruende #0b0d1c (Seite),
#0e0f17 (.glass) und #14141d (.popover) – der schlechteste Wert
zaehlt. */
--color-slate-500: #8a98b0; /* 6,3 : 1 */
--color-slate-600: #78879f; /* 5,0 : 1 */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+16 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { ChangelogButton } from "@/components/ChangelogButton";
import { CommandPalette } from "@/components/CommandPalette";
import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session";
@@ -20,6 +21,20 @@ export async function generateMetadata(): Promise<Metadata> {
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME,
robots: { index: false, follow: false },
manifest: "/manifest.webmanifest",
// iOS liest das Manifest nicht; dort braucht es diese Angaben einzeln.
appleWebApp: { capable: true, title: APP_NAME, statusBarStyle: "black-translucent" },
// Beide Eintraege ausdruecklich: sobald hier etwas steht, ersetzt es den
// Verweis, den Next aus app/icon.svg von selbst erzeugt. Ohne die erste
// Zeile haette die Seite gar kein Symbol mehr und der Browser fiele auf
// /favicon.ico zurueck – das es hier nicht gibt.
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
};
}
@@ -56,7 +71,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">v{APP_VERSION}</span>
<ChangelogButton version={APP_VERSION} />
</div>
</footer>
</body>
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
/**
* Web-App-Manifest: macht Nebula auf dem Handy installierbar.
*
* Als Funktion und nicht als Konstante, aus demselben Grund wie bei
* generateMetadata: nur so wird APP_NAME zur Laufzeit gelesen. Ein
* konstanter Export waere beim Build eingefroren und liesse sich im
* fertigen Docker-Image nicht mehr aendern.
*/
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${APP_NAME} – ${APP_TAGLINE} Dashboard`,
short_name: APP_NAME,
description:
"Uebersicht ueber alle Vibe-Coding-Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
// Ohne Adressleiste, aber mit den Systemleisten – die Anwendung braucht
// keinen Vollbildmodus, wohl aber die Uhrzeit und den Akkustand.
display: "standalone",
start_url: "/",
scope: "/",
background_color: "#05060f",
theme_color: "#05060f",
lang: "de",
orientation: "any",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Getrennte Fassung mit mehr Rand: Android schneidet maskierbare
// Symbole je nach Geraet rund, eckig oder als Tropfen zu.
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
shortcuts: [
{ name: "Dashboard", url: "/" },
{ name: "Mein Konto", url: "/account" },
],
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function DashboardPage() {
const ideas = active.filter((project) => project.status === "IDEA").length;
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
const done = active.filter((project) => project.status === "DONE").length;
const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length;
const linked = active.filter((project) => project.repositories.length > 0).length;
const averageProgress =
active.length > 0
+33 -2
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { FileText } from "lucide-react";
import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel";
import { ReadmePanel } from "@/components/ReadmePanel";
import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
@@ -53,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) {
const detail = project as unknown as ProjectDetail;
const fuehrend = detail.repositories[0]?.snapshot;
const readme = fuehrend?.readme
? {
content: fuehrend.readme,
path: fuehrend.readmePath,
baseUrl: fuehrend.readmeBaseUrl,
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
fileUrl:
fuehrend.readmeBaseUrl && fuehrend.readmePath
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
: (fuehrend.htmlUrl ?? null),
}
: null;
return (
<div className="space-y-6 pt-4">
<ProjectHeader
@@ -82,11 +98,26 @@ export default async function ProjectPage({ params }: PageProps) {
)}
</section>
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
{readme ? (
<ReadmePanel
content={readme.content}
path={readme.path}
baseUrl={readme.baseUrl}
fileUrl={readme.fileUrl}
/>
) : null}
<NotesPanel projectId={detail.id} notes={detail.notes} />
</div>
<div className="lg:col-span-7">
<GitPanel project={detail} />
<div className="space-y-6 lg:col-span-7">
<GitPanel
project={detail}
connections={connections as unknown as ConnectionSafe[]}
/>
<ActivityFeed projectId={detail.id} initial={detail.activities} />
</div>
</div>
</div>
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import {
AlertTriangle,
ArrowRight,
Bot,
Download,
GitCommitHorizontal,
History,
Link2,
Loader2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityEntry, ActivityKind } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils";
/**
* Der Verlauf eines Projekts: was ist wann passiert.
*
* Die erste Seite kommt mit der Seite mit, weitere holt der Knopf nach. Die
* Alternative – alles gleich mitzuladen – wuerde jeden Seitenaufruf mit
* zweihundert Eintraegen belasten, von denen man selten mehr als die
* obersten fuenf liest.
*/
const KINDS: Record<ActivityKind, { icon: typeof Sparkles; color: string }> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" },
};
export function ActivityFeed({
projectId,
initial,
}: {
projectId: string;
initial: ActivityEntry[];
}) {
const [entries, setEntries] = useState(initial);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Kam eine volle Seite, gibt es womoeglich noch mehr.
const [complete, setComplete] = useState(initial.length < 20);
async function loadAll() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/projects/${projectId}/activity?limit=200`);
if (!response.ok) throw new Error("Verlauf konnte nicht geladen werden.");
const payload = await response.json();
setEntries(payload.activities as ActivityEntry[]);
setComplete(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
return (
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<History className="h-4 w-4 text-violet-300" />
Verlauf
</h2>
{entries.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">
Noch nichts passiert. Sobald du etwas änderst, steht es hier.
</p>
) : (
<ol className="mt-4 space-y-0">
{entries.map((entry, index) => {
const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED;
const Icon = meta.icon;
const letzter = index === entries.length - 1;
return (
<li key={entry.id} className="relative flex gap-3 pb-4">
{/* Die Linie endet am letzten Eintrag, statt ins Leere zu laufen. */}
{!letzter ? (
<span
aria-hidden
className="absolute left-3 top-7 h-[calc(100%-1.25rem)] w-px bg-white/15"
/>
) : null}
<span className="relative z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#0d1020] ring-1 ring-white/10">
<Icon className={`h-3.5 w-3.5 ${meta.color}`} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{entry.summary}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<time dateTime={new Date(entry.createdAt).toISOString()}>
{formatRelative(entry.createdAt)}
</time>
<span aria-hidden>·</span>
<span>{formatDateTime(entry.createdAt)}</span>
{/* Ohne Benutzer war es der automatische Abgleich. */}
{entry.user ? null : (
<>
<span aria-hidden>·</span>
<Bot className="h-3 w-3" aria-hidden />
<span>automatisch</span>
</>
)}
</p>
</div>
</li>
);
})}
</ol>
)}
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
{!complete ? (
<button
type="button"
onClick={loadAll}
disabled={loading}
className="btn btn-ghost mt-1 w-full text-xs"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Älteres anzeigen
</button>
) : null}
</section>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Star, Tag, Trash2, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
/**
* Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist,
* und liegt fest am unteren Rand – bei einer langen Liste waere sie oben
* sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt.
*/
export function BulkBar({
ids,
onDone,
onClear,
}: {
ids: string[];
onDone: () => void;
onClear: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const statusRef = useRef<HTMLButtonElement>(null);
const tagsRef = useRef<HTMLButtonElement>(null);
async function apply(body: Record<string, unknown>) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
onDone();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
setStatusOpen(false);
setTagsOpen(false);
}
}
async function remove() {
if (
!window.confirm(
`${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` +
"unwiderruflich loeschen?",
)
) {
return;
}
await apply({ action: "delete" });
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4">
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt
</span>
<span className="h-5 w-px bg-white/10" aria-hidden />
<button
ref={statusRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setStatusOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={statusOpen}
>
Status
</button>
<Popover
open={statusOpen}
anchorRef={statusRef}
onClose={() => setStatusOpen(false)}
align="start"
>
<div role="menu" className="min-w-[12rem] p-1">
{STATUS_ORDER.map((status) => (
<button
key={status}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-slate-300 hover:bg-white/6"
onClick={() => apply({ action: "status", status })}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_META[status].color }}
/>
{STATUS_META[status].label}
</button>
))}
</div>
</Popover>
<button
ref={tagsRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTagsOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={tagsOpen}
>
<Tag className="h-3.5 w-3.5" />
Tags
</button>
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start">
<div className="w-64 space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
<input
id="bulk-tags"
className="field"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
placeholder="experiment, archiv"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
className="btn btn-primary flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "add" })
}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-ghost flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "remove" })
}
>
Entfernen
</button>
</div>
</div>
</Popover>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => apply({ action: "favorite", favorite: true })}
disabled={busy}
title="Als Favoriten markieren"
>
<Star className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Löschen
</button>
{busy ? <Loader2 className="h-4 w-4 animate-spin text-slate-400" /> : null}
<button
type="button"
onClick={onClear}
className="rounded-lg p-1.5 text-slate-500 hover:text-white"
aria-label="Auswahl aufheben"
>
<X className="h-4 w-4" />
</button>
{error ? (
<p className={cn("w-full px-1 text-xs text-rose-300")}>{error}</p>
) : null}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { CHANGELOG, KIND_META } from "@/lib/changelog";
import { cn, formatDate } from "@/lib/utils";
/**
* Die Versionsnummer in der Fusszeile oeffnet den Aenderungsverlauf.
*
* Kein eigener Menuepunkt: gesucht wird so etwas erfahrungsgemaess dort, wo
* die Version steht – und einen Platz in der Navigation ist es nicht wert.
*/
export function ChangelogButton({ version }: { version: string }) {
const [open, setOpen] = useState(false);
const aktuell = CHANGELOG[0]?.version;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded font-mono text-slate-600 underline decoration-dotted underline-offset-4 transition-colors hover:text-violet-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
title="Was ist neu?"
>
v{version}
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
width="lg"
title="Änderungsverlauf"
description={
// Ein Hinweis, falls die laufende Fassung neuer ist als der Eintrag.
// Das passiert, wenn jemand die Version hochzaehlt und den Eintrag
// vergisst – dann soll es auffallen statt still danebenzustehen.
aktuell === version
? "Was in den letzten Fassungen dazugekommen ist."
: `Diese Instanz läuft auf v${version}; der Verlauf endet bei v${aktuell}.`
}
>
<ol className="space-y-7">
{CHANGELOG.map((release) => (
<li key={release.version}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-mono text-base font-semibold text-white">
v{release.version}
</h3>
{release.version === version ? (
<span className="rounded-full bg-violet-500/15 px-2 py-0.5 text-[10px] uppercase tracking-wider text-violet-200 ring-1 ring-violet-400/30">
installiert
</span>
) : null}
<span className="text-xs text-slate-500">{formatDate(release.date)}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{release.headline}</p>
<ul className="mt-3 space-y-2">
{release.changes.map((change, index) => {
const meta = KIND_META[change.kind];
return (
<li key={index} className="flex gap-2.5 text-sm leading-snug text-slate-300">
<span
className={cn(
"mt-0.5 h-fit shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1",
meta.className,
)}
>
{meta.label}
</span>
<span className="min-w-0">{change.text}</span>
</li>
);
})}
</ul>
</li>
))}
</ol>
</Modal>
</>
);
}
+4 -4
View File
@@ -140,8 +140,8 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
}
async function remove(connection: ConnectionWithCount) {
const linked = connection._count?.projects ?? 0;
const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : "";
const linked = connection._count?.repositories ?? 0;
const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : "";
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
setBusyId(connection.id);
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
@@ -257,9 +257,9 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
</div>
) : null}
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Projekte</dt>
<dt className="text-slate-500">Repositories</dt>
<dd className="tabular-nums text-slate-300">
{connection._count?.projects ?? 0}
{connection._count?.repositories ?? 0}
</dd>
</div>
{connection.lastCheckedAt ? (
+167 -28
View File
@@ -16,19 +16,26 @@ import {
Loader2,
Lock,
Package,
Plus,
RefreshCw,
ShieldCheck,
Star,
Tag,
Unlink,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline";
import { asArray, asRecord } from "@/types";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types";
import type {
BranchInfo,
CommitInfo,
ConnectionSafe,
ProjectDetail,
ReleaseInfo,
RepositoryWithSnapshot,
TagInfo,
TicketInfo,
} from "@/types";
@@ -45,22 +52,37 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) {
export function GitPanel({
project,
connections,
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
}) {
const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false);
const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [addOpen, setAddOpen] = useState(false);
const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() {
if (!repository) return;
setImporting(true);
setMessage(null);
try {
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
body: JSON.stringify({ repositoryId: repository.id }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
@@ -79,7 +101,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
}
}
const snapshot = project.snapshot;
const snapshot = repository?.snapshot ?? null;
const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags);
const commits = asArray<CommitInfo>(snapshot?.commits);
@@ -88,14 +110,17 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const calendar = asCalendar(snapshot?.commitCalendar);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
async function sync() {
/** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) {
setSyncing(true);
setMessage(null);
try {
const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" });
const url = nurDieses
? `/api/repositories/${nurDieses}/sync`
: `/api/projects/${project.id}/sync`;
const response = await fetch(url, { method: "POST" });
const payload = await response.json().catch(() => ({}));
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
router.refresh();
@@ -109,16 +134,41 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
}
}
if (!hasRepo) {
async function removeRepository(entry: RepositoryWithSnapshot) {
if (
!window.confirm(
`„${repoTitle(entry)}“ vom Projekt loesen? Der geholte Stand geht dabei verloren.`,
)
) {
return;
}
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setSelectedId(null);
router.refresh();
}
if (!repository) {
return (
<div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um
Branches, Tags, Commits und Releases hier zu sehen.
</p>
</div>
<>
<div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
</p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" />
Repository verknuepfen
</button>
</div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
</>
);
}
@@ -129,12 +179,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
return (
<div className="space-y-4">
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
Leiste mit genau einem Knopf nur Ballast. */}
{repositories.length > 1 ? (
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
{repositories.map((entry) => {
const aktiv = entry.id === repository.id;
return (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={aktiv}
onClick={() => setSelectedId(entry.id)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors",
aktiv
? "bg-white/9 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
)}
>
{entry.syncError ? (
<AlertTriangle className="h-3 w-3 text-rose-300" />
) : (
<GitBranch className="h-3 w-3 text-slate-500" />
)}
{repoTitle(entry)}
</button>
);
})}
<button
type="button"
onClick={() => setAddOpen(true)}
className="ml-auto rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/5 hover:text-violet-300"
aria-label="Weiteres Repository verknuepfen"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
) : null}
{/* Kopf mit Sync */}
<div className="glass flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate font-mono text-sm text-slate-100">
{snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`}
{snapshot?.fullName ?? repoTitle(repository)}
</h2>
{snapshot?.visibility ? (
<span className="inline-flex items-center gap-1 rounded-md bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
@@ -160,22 +251,67 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}`
{repository.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}`
: "Noch nie synchronisiert."}
</p>
</div>
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
<div className="flex flex-wrap items-center gap-2">
{repositories.length > 1 ? (
<button
type="button"
className="btn btn-ghost"
onClick={() => sync()}
disabled={syncing}
title="Alle Repositories dieses Projekts abgleichen"
>
<RefreshCw className="h-4 w-4" />
Alle
</button>
) : (
<RefreshCw className="h-4 w-4" />
<button
type="button"
onClick={() => setAddOpen(true)}
className="btn btn-ghost"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-4 w-4" />
Repository
</button>
)}
Jetzt synchronisieren
</button>
<button
type="button"
onClick={() => removeRepository(repository)}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-rose-500/10 hover:text-rose-300"
aria-label={`${repoTitle(repository)} vom Projekt loesen`}
title="Vom Projekt loesen"
>
<Unlink className="h-4 w-4" />
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => sync(repository.id)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Jetzt synchronisieren
</button>
</div>
</div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
{message ? (
<p
className={cn(
@@ -189,10 +325,10 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</p>
) : null}
{project.syncError && !message ? (
{repository.syncError && !message ? (
<p className="flex items-start gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{project.syncError}</span>
<span>{repository.syncError}</span>
</p>
) : null}
@@ -282,6 +418,9 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div>
) : null}
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
{calendar ? <Heatmap calendar={calendar} /> : null}
{/* Sprachen */}
{languageTotal > 0 ? (
<div className="glass p-4">
+184
View File
@@ -0,0 +1,184 @@
import { CalendarRange } from "lucide-react";
import type { CommitCalendar } from "@/lib/git/calendar";
import { formatDate } from "@/lib/utils";
/**
* Commits je Tag als Kalender – ein Feld pro Tag, Wochen als Spalten.
*
* Wie die Verlaufslinie ohne Diagrammbibliothek: es sind Quadrate in einem
* Raster, dafuer braucht es keine Achsen und keine Legende ausser der
* Farbskala.
*
* Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen.
* Wo die Seiten des Anbieters nur ein paar Monate zurueckreichen, faengt die
* Heatmap dort an – ein leeres Feld wuerde sonst behaupten, an dem Tag sei
* nicht gearbeitet worden, obwohl darueber nichts bekannt ist.
*/
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const MONATE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
/** Fuenf Stufen, damit ein einzelner Ausreisser nicht alles andere platt macht. */
function stufe(anzahl: number, hoch: number): number {
if (anzahl <= 0) return 0;
if (hoch <= 1) return 4;
const anteil = anzahl / hoch;
if (anteil > 0.75) return 4;
if (anteil > 0.5) return 3;
if (anteil > 0.25) return 2;
return 1;
}
const FARBEN = [
"fill-white/6",
"fill-violet-500/25",
"fill-violet-500/45",
"fill-violet-400/70",
"fill-violet-300",
];
/** Montag der Woche, in der dieser Tag liegt (UTC). */
function wochenanfang(tag: Date): Date {
const kopie = new Date(tag);
// getUTCDay: 0 = Sonntag. Die Woche soll am Montag beginnen.
const versatz = (kopie.getUTCDay() + 6) % 7;
kopie.setUTCDate(kopie.getUTCDate() - versatz);
kopie.setUTCHours(0, 0, 0, 0);
return kopie;
}
export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
const von = new Date(`${calendar.from}T00:00:00.000Z`);
const bis = new Date(`${calendar.to}T00:00:00.000Z`);
if (Number.isNaN(von.getTime()) || Number.isNaN(bis.getTime()) || von > bis) return null;
const start = wochenanfang(von);
const wochen: Array<Array<{ key: string; anzahl: number; ausserhalb: boolean }>> = [];
let hoechstwert = 0;
for (const wert of Object.values(calendar.days)) {
if (wert > hoechstwert) hoechstwert = wert;
}
const zeiger = new Date(start);
while (zeiger <= bis) {
const woche: Array<{ key: string; anzahl: number; ausserhalb: boolean }> = [];
for (let tag = 0; tag < 7; tag += 1) {
const key = zeiger.toISOString().slice(0, 10);
// Vor dem ersten bekannten Tag und nach heute wird nichts gezeichnet.
const ausserhalb = zeiger < von || zeiger > bis;
woche.push({ key, anzahl: calendar.days[key] ?? 0, ausserhalb });
zeiger.setUTCDate(zeiger.getUTCDate() + 1);
}
wochen.push(woche);
}
const gesamt = Object.values(calendar.days).reduce((sum, wert) => sum + wert, 0);
const tageMitCommits = Object.keys(calendar.days).length;
// Masse in SVG-Einheiten; die Breite ergibt sich aus der Zahl der Wochen.
const feld = 11;
const luecke = 3;
const kopf = 14;
const spalte = feld + luecke;
const breite = wochen.length * spalte - luecke;
const hoehe = kopf + 7 * spalte - luecke;
// Monatsbeschriftung an der Woche, in der ein neuer Monat beginnt.
const monate: Array<{ x: number; label: string }> = [];
let letzterMonat = -1;
wochen.forEach((woche, index) => {
const ersterTag = new Date(`${woche[0].key}T00:00:00.000Z`);
const monat = ersterTag.getUTCMonth();
if (monat !== letzterMonat) {
letzterMonat = monat;
monate.push({ x: index * spalte, label: MONATE[monat] });
}
});
return (
<div className="glass p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<CalendarRange className="h-3 w-3" />
Commits je Tag
</p>
<p className="text-[11px] text-slate-500">
{gesamt} an {tageMitCommits} {tageMitCommits === 1 ? "Tag" : "Tagen"} ·{" "}
{/* Der Zeitraum gehoert dazu: er ist nicht immer ein Jahr. */}
seit {formatDate(calendar.from)}
{calendar.truncated ? " (Älteres nicht abgerufen)" : ""}
</p>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${breite + 22} ${hoehe}`}
width={breite + 22}
height={hoehe}
className="max-w-full"
role="img"
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
>
{monate.map((monat, index) => (
<text
key={`${monat.label}-${index}`}
x={monat.x + 22}
y={9}
className="fill-slate-500 text-[8px]"
>
{monat.label}
</text>
))}
{/* Nur jeder zweite Wochentag, sonst steht es zu eng. */}
{WOCHENTAGE.map((tag, index) =>
index % 2 === 1 ? (
<text
key={tag}
x={0}
y={kopf + index * spalte + feld - 2}
className="fill-slate-600 text-[7px]"
>
{tag}
</text>
) : null,
)}
{wochen.map((woche, x) =>
woche.map((tag, y) =>
tag.ausserhalb ? null : (
<rect
key={tag.key}
x={x * spalte + 22}
y={kopf + y * spalte}
width={feld}
height={feld}
rx={2}
className={FARBEN[stufe(tag.anzahl, hoechstwert)]}
>
<title>
{`${formatDate(tag.key)}: ${tag.anzahl} ${tag.anzahl === 1 ? "Commit" : "Commits"}`}
</title>
</rect>
),
),
)}
</svg>
</div>
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] text-slate-600">
<span>weniger</span>
{FARBEN.map((farbe, index) => (
<svg key={index} width={9} height={9} aria-hidden>
<rect width={9} height={9} rx={2} className={farbe} />
</svg>
))}
<span>mehr</span>
</div>
</div>
);
}
+182 -69
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -8,53 +8,81 @@ import {
DragOverlay,
PointerSensor,
TouchSensor,
closestCorners,
useDraggable,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GitBranch, GripVertical, Loader2, 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";
type Columns = Record<ProjectStatus, ProjectListItem[]>;
/**
* Statusspalten mit Ziehen und Ablegen.
* Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
*
* 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.
* Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
* gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
* nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
* Gruppen, hier zaehlt die Anordnung von Hand.
*
* Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert
* das Speichern, springt alles auf den Serverstand zurueck.
*/
export function KanbanBoard({
projects,
showArchived,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
showArchived: boolean;
selection: Set<string>;
onToggleSelect: (id: string, selected: boolean) => void;
}) {
const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 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;
const sichtbar = useMemo(
() => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
[showArchived],
);
const group = useMemo(
() => (list: ProjectListItem[]): Columns => {
const leer = Object.fromEntries(
STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]),
) as Columns;
for (const project of list) leer[project.status].push(project);
for (const status of STATUS_ORDER) {
// Bei gleicher Position bleibt es bei der Reihenfolge, in der die
// Liste kam – das ist die im Kopf gewaehlte Sortierung.
leer[status].sort((a, b) => a.position - b.position);
}
return Object.keys(next).length === Object.keys(current).length ? current : next;
});
}, [projects]);
return leer;
},
[],
);
const [columns, setColumns] = useState<Columns>(() => group(projects));
// Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann
// noch der alte und wuerde die Karte zurueckspringen lassen.
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setColumns(group(projects));
}, [projects, group]);
const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
@@ -63,72 +91,138 @@ export function KanbanBoard({
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status;
const active = activeId
? Object.values(columns).flat().find((project) => project.id === activeId) ?? null
: null;
const columns = useMemo(() => {
const visible = STATUS_ORDER.filter(
(status) => showArchived || status !== "ARCHIVED",
/** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
function containerOf(key: string): ProjectStatus | null {
if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus;
return (
STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null
);
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) {
dragging.current = true;
setActiveId(String(event.active.id));
}
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((project) => project.id === activeKey);
if (!moved) return current;
const zielIndex = current[to].findIndex((project) => project.id === overKey);
const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length;
return {
...current,
[from]: current[from].filter((project) => project.id !== activeKey),
[to]: [
...current[to].slice(0, einfuegenBei),
{ ...moved, status: to },
...current[to].slice(einfuegenBei),
],
};
});
}
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 ueber = containerOf(overKey);
if (container && container === ueber) {
const list = columns[container];
const alt = list.findIndex((project) => project.id === activeKey);
const neu = list.findIndex((project) => project.id === overKey);
if (alt >= 0 && neu >= 0 && alt !== neu) {
const sortiert = [...list];
const [moved] = sortiert.splice(alt, 1);
sortiert.splice(neu, 0, moved);
next = { ...columns, [container]: sortiert };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
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 }));
async function persist(next: Columns) {
setSaving(true);
try {
const response = await fetch(`/api/projects/${id}`, {
const response = await fetch("/api/projects/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }),
body: JSON.stringify({
columns: Object.fromEntries(
sichtbar.map((status) => [status, next[status].map((project) => project.id)]),
),
}),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
setOverrides((current) => {
const next = { ...current };
delete next[id];
return next;
});
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(projects));
} finally {
setSaving(false);
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(projects));
}}
>
{saving ? (
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Reihenfolge wird gespeichert…
</p>
) : 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} />
))}
{sichtbar.map((status) => (
<Column key={status} status={status} count={columns[status].length}>
<SortableContext
items={columns[status].map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{columns[status].map((project) => (
<KanbanCard
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</SortableContext>
</Column>
))}
</div>
@@ -196,28 +290,36 @@ function Column({
function KanbanCard({
project,
overlay = false,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
overlay?: boolean;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
return (
<article
ref={overlay ? undefined : setNodeRef}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
} as React.CSSProperties
}
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",
selected && "ring-1 ring-violet-400/60",
)}
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" />
@@ -233,6 +335,16 @@ function KanbanCard({
<GripVertical className="h-3.5 w-3.5" />
</button>
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
aria-label={`${project.name} auswaehlen`}
/>
) : null}
<div className="min-w-0 flex-1">
<Link
href={`/projects/${project.id}`}
@@ -256,11 +368,12 @@ function KanbanCard({
</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 ? (
{/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */}
<CiBadge state={project.repositories[0]?.snapshot?.ciState} compact />
{project.repositories[0]?.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" />
{project.snapshot.defaultBranch}
{project.repositories[0].snapshot.defaultBranch}
</span>
) : null}
{project._count.notes > 0 ? (
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
},
};
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({
children,
className,
baseUrl,
}: {
children: string | null | undefined;
className?: string;
/**
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
* Basis ins Leere liefen.
*/
baseUrl?: string | null;
}) {
if (!children?.trim()) return null;
@@ -37,12 +118,22 @@ export function Markdown({
<div className={cn("md", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]}
// Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
// die Bereinigung laufen und dabei ihre Klassen verlieren.
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
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">
// className muss durchgereicht werden, sonst verlieren die
// Bildmarken aus dem Umbau oben ihre Gestalt.
a: ({ href, className: klasse, title, children: content }) => (
<a
href={absolut(href, baseUrl)}
className={klasse}
title={title}
target="_blank"
rel="noreferrer noopener nofollow"
>
{content}
</a>
),
+108 -4
View File
@@ -16,6 +16,7 @@ import {
X,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard";
@@ -54,6 +55,11 @@ function highlight(snippet: string): React.ReactNode[] {
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
created: "Neueste zuerst",
@@ -74,11 +80,52 @@ export function ProjectBoard({
const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
/**
* Ansicht und Sortierung ueberdauern das Neuladen.
*
* Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch
* sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern
* gelesen: der Server kennt den gespeicherten Wert nicht, und ein
* Unterschied zwischen beiden waere ein Hydrationsfehler.
*
* Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen
* wiederherzustellen waere eher verwirrend als hilfreich.
*/
useEffect(() => {
try {
const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL);
if (!gespeichert) return;
const wert = JSON.parse(gespeichert) as Partial<{
view: ViewMode;
sort: SortKey;
showArchived: boolean;
}>;
if (wert.view && VIEWS.includes(wert.view)) setView(wert.view);
if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort);
if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived);
} catch {
// Kaputter oder gesperrter Speicher: dann eben die Voreinstellung.
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
ANSICHT_SCHLUESSEL,
JSON.stringify({ view, sort, showArchived }),
);
} catch {
// Im privaten Modus mancher Browser nicht beschreibbar – kein Grund,
// die Seite scheitern zu lassen.
}
}, [view, sort, showArchived]);
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[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
@@ -140,7 +187,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.repositories.some((repo) =>
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
) ||
// Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id)
);
@@ -172,6 +221,28 @@ export function ProjectBoard({
})).filter((group) => group.items.length > 0);
}, [visible]);
/**
* Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine
* Massenaenderung Projekte, die man gar nicht mehr vor Augen hat.
*/
useEffect(() => {
setSelection((current) => {
if (current.size === 0) return current;
const sichtbar = new Set(visible.map((project) => project.id));
const next = new Set([...current].filter((id) => sichtbar.has(id)));
return next.size === current.size ? current : next;
});
}, [visible]);
function toggleSelect(id: string, selected: boolean) {
setSelection((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) =>
current.includes(status)
@@ -216,6 +287,14 @@ export function ProjectBoard({
className="field w-auto min-w-[11rem]"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
// stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend.
disabled={view === "kanban"}
title={
view === "kanban"
? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst."
: undefined
}
>
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}>
@@ -386,11 +465,22 @@ export function ProjectBoard({
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} />
<KanbanBoard
projects={visible}
showArchived={showArchived}
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
<ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
) : (
@@ -414,7 +504,13 @@ export function ProjectBoard({
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
<ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
</div>
@@ -423,6 +519,14 @@ export function ProjectBoard({
</div>
)}
{selection.size > 0 ? (
<BulkBar
ids={[...selection]}
onDone={() => setSelection(new Set())}
onClear={() => setSelection(new Set())}
/>
) : null}
<Popover
anchorRef={dataButtonRef}
open={dataMenuOpen}
+53 -10
View File
@@ -14,6 +14,7 @@ import {
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils";
import { asArray, type ProjectListItem } from "@/types";
@@ -28,10 +29,27 @@ const PROVIDER_LABEL: Record<string, string> = {
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) {
export function ProjectCard({
project,
index = 0,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
index?: number;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot;
// Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
// insgesamt sind, sagt die Zahl daneben.
const leading = project.repositories[0] ?? null;
const snapshot = leading?.snapshot ?? null;
const weitere = project.repositories.length - 1;
// Ein Fehler an irgendeinem Repo gehoert auf die Karte, nicht nur einer am
// fuehrenden – sonst bliebe er unbemerkt.
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite);
@@ -52,7 +70,10 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
return (
<article
className="glass card-glow fade-up group relative overflow-hidden p-5"
className={cn(
"glass card-glow fade-up group relative overflow-hidden p-5",
selected && "ring-1 ring-violet-400/60",
)}
style={
{
"--accent-from": accent.from,
@@ -73,6 +94,21 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Gedaempft, aber immer da. Nur beim Ueberfahren einzublenden
waere auf einem Touchgeraet gar nicht erreichbar – und wer
es nie sieht, weiss nicht, dass es die Auswahl gibt. */}
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-3.5 w-3.5 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name}
</h3>
@@ -125,10 +161,20 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<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 ? (
{leading?.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" />
{PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider}
{PROVIDER_LABEL[leading.connection.provider] ?? leading.connection.provider}
</span>
) : null}
{weitere > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={project.repositories.map((repo) => repoTitle(repo)).join(", ")}
>
<GitBranch className="h-3.5 w-3.5 text-slate-500" />+{weitere} Repo
{weitere === 1 ? "" : "s"}
</span>
) : null}
@@ -173,11 +219,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span>
) : null}
{project.syncError ? (
<span
className="inline-flex items-center gap-1.5 text-rose-300/80"
title={project.syncError}
>
{syncError ? (
<span className="inline-flex items-center gap-1.5 text-rose-300/80" title={syncError}>
<AlertTriangle className="h-3.5 w-3.5" />
Sync-Fehler
</span>
+148 -8
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
@@ -17,6 +17,33 @@ interface ProjectDialogProps {
project?: ProjectListItem | null;
}
/** Eine Vorlage, so wie sie /api/templates liefert. */
export interface ProjectTemplateEntry {
id: string;
name: string;
description: string | null;
summary: string | null;
projectDescription: string | null;
status: ProjectStatus;
priority: number;
accent: string;
tags: string[];
progressFromTasks: boolean;
notes: unknown[];
tasks: unknown[];
}
/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */
function beschreibeVorlage(vorlage: ProjectTemplateEntry): string {
const teile: string[] = [];
const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0;
const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0;
if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`);
if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`);
if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" "));
return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten";
}
interface FormState {
name: string;
summary: string;
@@ -33,6 +60,8 @@ interface FormState {
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
}
const EMPTY: FormState = {
@@ -51,6 +80,7 @@ const EMPTY: FormState = {
repoName: "",
autoSync: true,
progressFromTasks: false,
templateId: "",
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(project);
const [templates, setTemplates] = useState<ProjectTemplateEntry[]>([]);
const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null;
/**
* Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum
* Anlegen. Beim Bearbeiten waeren sie nur Ballast.
*/
useEffect(() => {
if (!open || isEdit) return;
let abgebrochen = false;
fetch("/api/templates")
.then((response) => (response.ok ? response.json() : { templates: [] }))
.then((payload) => {
if (!abgebrochen) setTemplates(payload.templates ?? []);
})
.catch(() => undefined);
return () => {
abgebrochen = true;
};
}, [open, isEdit]);
/**
* Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben
* bleiben der Serverseite ueberlassen: sie liessen sich hier nicht
* darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht
* mitgeschleppt bekommen.
*
* Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden
* gefuellt.
*/
function applyTemplate(templateId: string) {
const vorlage = templates.find((entry) => entry.id === templateId);
if (!vorlage) {
update("templateId", "");
return;
}
setForm((previous) => ({
...previous,
templateId,
name: previous.name || vorlage.name,
summary: previous.summary || (vorlage.summary ?? ""),
description: previous.description || (vorlage.projectDescription ?? ""),
status: vorlage.status,
priority: vorlage.priority,
accent: vorlage.accent,
tags: previous.tags || vorlage.tags.join(", "),
progressFromTasks: vorlage.progressFromTasks,
}));
}
async function removeTemplate(vorlage: ProjectTemplateEntry) {
if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return;
await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined);
setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id));
update("templateId", "");
}
const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => {
if (!open) return;
setError(null);
@@ -87,12 +175,16 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
accent: project.accent,
tags: project.tags.join(", "),
favorite: project.favorite,
connectionId: project.connectionId ?? "",
repoUrl: project.repoUrl ?? "",
repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "",
autoSync: project.autoSync,
// Der Dialog bearbeitet das fuehrende Repository. Weitere
// verwaltet das Git-Panel – hier wuerde eine Liste den Dialog
// sprengen, in dem es eigentlich um das Projekt geht.
connectionId: project.repositories[0]?.connectionId ?? "",
repoUrl: project.repositories[0]?.repoUrl ?? "",
repoOwner: project.repositories[0]?.repoOwner ?? "",
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks,
templateId: "",
}
: EMPTY,
);
@@ -162,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
};
try {
@@ -200,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
}
>
<form onSubmit={submit} className="space-y-5">
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer
Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */}
{!isEdit && templates.length > 0 ? (
<div className="glass-soft p-4">
<label className="field-label" htmlFor="project-template">
Aus Vorlage
</label>
<div className="flex flex-wrap items-center gap-2">
<select
id="project-template"
className="field w-auto min-w-[14rem] flex-1"
value={form.templateId}
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– ohne Vorlage –</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
{gewaehlteVorlage ? (
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={() => removeTemplate(gewaehlteVorlage)}
title="Diese Vorlage loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{gewaehlteVorlage ? (
<p className="mt-1.5 text-xs text-slate-500">
{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""}
{beschreibeVorlage(gewaehlteVorlage)}
</p>
) : (
<p className="mt-1.5 text-xs text-slate-500">
Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter
„Als Vorlage sichern“.
</p>
)}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name">
+38 -18
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
BookmarkPlus,
CalendarDays,
CheckSquare,
Download,
@@ -17,8 +18,10 @@ import {
Trash2,
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { SaveTemplateDialog } from "@/components/SaveTemplateDialog";
import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatDate } from "@/lib/utils";
import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -35,6 +38,7 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [templateOpen, setTemplateOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite);
@@ -132,24 +136,22 @@ export function ProjectHeader({
angelegt {formatDate(project.createdAt)}
</span>
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
{project.connection ? (
<span className="inline-flex items-center gap-1.5">
{project.connection.name}
<span className="text-slate-700">·</span>
{project.connection.provider}
</span>
) : null}
{project.repoUrl ? (
<a
href={project.repoUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
>
Repository
<ExternalLink className="h-3 w-3" />
</a>
) : null}
{/* Alle Repositories verlinken – bei einem einzigen sieht das aus
wie vorher, bei mehreren steht jedes fuer sich. */}
{project.repositories
.filter((repository) => repository.repoUrl)
.map((repository) => (
<a
key={repository.id}
href={repository.repoUrl!}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
>
{project.repositories.length === 1 ? "Repository" : repoTitle(repository)}
<ExternalLink className="h-3 w-3" />
</a>
))}
</div>
{project.tags.length > 0 ? (
@@ -180,6 +182,14 @@ export function ProjectHeader({
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTemplateOpen(true)}
title="Als Vorlage sichern"
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -285,6 +295,16 @@ export function ProjectHeader({
connections={connections}
project={project}
/>
<SaveTemplateDialog
open={templateOpen}
onClose={() => setTemplateOpen(false)}
projectId={project.id}
projectName={project.name}
noteCount={project.notes.length}
// Erledigte kommen nicht mit – hier steht, was wirklich uebernommen wird.
taskCount={project.tasks.filter((task) => task.status !== "DONE").length}
/>
</header>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}
+334
View File
@@ -0,0 +1,334 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import type { ConnectionSafe, RepoListEntry, RepositoryWithSnapshot } from "@/types";
/**
* Ein weiteres Repository mit einem Projekt verknuepfen – oder ein
* vorhandenes umbenennen und umhaengen.
*
* Beim Anlegen des Projekts genuegt der Projektdialog: dort setzen die
* Git-Felder das erste Repository. Alles darueber hinaus laeuft hier.
*/
interface FormState {
label: string;
connectionId: string;
repoUrl: string;
repoOwner: string;
repoName: string;
autoSync: boolean;
}
const EMPTY: FormState = {
label: "",
connectionId: "",
repoUrl: "",
repoOwner: "",
repoName: "",
autoSync: true,
};
export function RepositoryDialog({
open,
onClose,
projectId,
connections,
repository,
}: {
open: boolean;
onClose: () => void;
projectId: string;
connections: ConnectionSafe[];
/** Vorhandenes = Bearbeiten, sonst Verknuepfen. */
repository?: RepositoryWithSnapshot | null;
}) {
const router = useRouter();
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const isEdit = Boolean(repository);
useEffect(() => {
if (!open) return;
setError(null);
setPickerOpen(false);
setRepos([]);
setFilter("");
setForm(
repository
? {
label: repository.label ?? "",
connectionId: repository.connectionId ?? "",
repoUrl: repository.repoUrl ?? "",
repoOwner: repository.repoOwner ?? "",
repoName: repository.repoName ?? "",
autoSync: repository.autoSync,
}
: EMPTY,
);
}, [open, repository]);
const gefiltert = useMemo(() => {
const needle = filter.toLowerCase().trim();
if (!needle) return repos.slice(0, 60);
return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60);
}, [repos, filter]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((previous) => ({ ...previous, [key]: value }));
}
async function loadRepos() {
if (!form.connectionId) {
setError("Bitte zuerst eine Git-Verbindung auswaehlen.");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/connections/${form.connectionId}/repos`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden.");
setRepos(payload.repos ?? []);
setPickerOpen(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
function pick(repo: RepoListEntry) {
setForm((previous) => ({
...previous,
repoUrl: repo.htmlUrl,
repoOwner: repo.owner,
repoName: repo.name,
}));
setPickerOpen(false);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch(
isEdit ? `/api/repositories/${repository?.id}` : `/api/projects/${projectId}/repositories`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label,
connectionId: form.connectionId || null,
repoUrl: form.repoUrl,
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
}),
},
);
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 {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={isEdit ? "Repository bearbeiten" : "Repository verknuepfen"}
description={
isEdit
? "Zeigt das Repository woandershin, wird der geholte Stand verworfen."
: "Ein Projekt darf mehrere Repositories haben – etwa Frontend und Backend."
}
>
<form onSubmit={submit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="repo-label">
Bezeichnung (optional)
</label>
<input
id="repo-label"
className="field"
value={form.label}
onChange={(event) => update("label", event.target.value)}
placeholder="Frontend"
maxLength={80}
/>
<p className="mt-1 text-xs text-slate-500">
Ohne Angabe steht schlicht owner/name da.
</p>
</div>
<div>
<label className="field-label" htmlFor="repo-connection">
Verbindung
</label>
<select
id="repo-connection"
className="field"
value={form.connectionId}
onChange={(event) => update("connectionId", event.target.value)}
>
<option value="">– keine –</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name} ({connection.provider})
</option>
))}
</select>
</div>
<div className="flex items-end">
<button
type="button"
className="btn btn-ghost w-full"
onClick={loadRepos}
disabled={loading || !form.connectionId}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Repos laden
</button>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="repo-url">
Repository-URL
</label>
<input
id="repo-url"
className="field"
value={form.repoUrl}
onChange={(event) => update("repoUrl", event.target.value)}
placeholder="https://github.com/user/repo"
/>
</div>
<div>
<label className="field-label" htmlFor="repo-owner">
Owner / Gruppe
</label>
<input
id="repo-owner"
className="field font-mono text-xs"
value={form.repoOwner}
onChange={(event) => update("repoOwner", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
<div>
<label className="field-label" htmlFor="repo-name">
Repository
</label>
<input
id="repo-name"
className="field font-mono text-xs"
value={form.repoName}
onChange={(event) => update("repoName", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.autoSync}
onChange={(event) => update("autoSync", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Am automatischen Abgleich teilnehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
synchronisieren geht unabhaengig davon immer.
</span>
</span>
</label>
{pickerOpen ? (
<div className="rounded-xl border border-white/8 bg-[#080a18]/70">
<div className="flex items-center gap-2 border-b border-white/6 px-3 py-2">
<Search className="h-3.5 w-3.5 text-slate-500" />
<input
className="w-full bg-transparent text-sm text-slate-200 outline-none placeholder:text-slate-600"
placeholder={`${repos.length} Repositories durchsuchen…`}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</div>
<ul className="max-h-56 overflow-y-auto p-1">
{gefiltert.map((repo) => (
<li key={repo.fullPath}>
<button
type="button"
onClick={() => pick(repo)}
className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left hover:bg-white/6"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs text-slate-200">
{repo.fullPath}
</span>
{repo.description ? (
<span className="block truncate text-[11px] text-slate-500">
{repo.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] uppercase text-slate-600">
{repo.visibility}
</span>
</button>
</li>
))}
{gefiltert.length === 0 ? (
<li className="px-3 py-4 text-center text-xs text-slate-500">Keine Treffer.</li>
) : null}
</ul>
</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}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Verknuepfen"}
</button>
</div>
</form>
</Modal>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { BookmarkPlus, Check, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
/**
* Aus dem offenen Projekt eine Vorlage machen.
*
* Bewusst hier und nicht in einer eigenen Verwaltungsseite: Vorlagen
* entstehen aus einem Projekt, das sich bewaehrt hat. Sie von Grund auf zu
* tippen kann man ueber die API, taeglich braucht das niemand.
*/
export function SaveTemplateDialog({
open,
onClose,
projectId,
projectName,
noteCount,
taskCount,
}: {
open: boolean;
onClose: () => void;
projectId: string;
projectName: string;
noteCount: number;
taskCount: number;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [withNotes, setWithNotes] = useState(true);
const [withTasks, setWithTasks] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setName(projectName);
setDescription("");
setWithNotes(true);
setWithTasks(true);
setError(null);
setDone(false);
}, [open, projectName]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/templates/from-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, name, description, withNotes, withTasks }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
setDone(true);
// Kurz stehen lassen, damit die Bestaetigung ankommt.
setTimeout(onClose, 900);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Als Vorlage sichern"
description="Neue Projekte starten damit mit denselben Tags, Notizen und Aufgaben."
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="template-name">
Name der Vorlage *
</label>
<input
id="template-name"
className="field"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
required
autoFocus
/>
</div>
<div>
<label className="field-label" htmlFor="template-description">
Wofür ist sie gut? (optional)
</label>
<input
id="template-description"
className="field"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="z. B. Kleine Web-App mit Docker und CI"
maxLength={280}
/>
</div>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withNotes}
onChange={(event) => setWithNotes(event.target.checked)}
disabled={noteCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Notizen übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{noteCount === 0
? "Dieses Projekt hat keine Notizen."
: `${noteCount} ${noteCount === 1 ? "Notiz" : "Notizen"}`}
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withTasks}
onChange={(event) => setWithTasks(event.target.checked)}
disabled={taskCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aufgaben übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{taskCount === 0
? "Dieses Projekt hat keine offenen Aufgaben."
: `${taskCount} offene ${taskCount === 1 ? "Aufgabe" : "Aufgaben"} — erledigte kommen nicht mit`}
</span>
</span>
</label>
</div>
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-xs text-slate-500">
Repositories gehen <strong>nicht</strong> mit. Eine Vorlage, die jedes neue Projekt
an dasselbe Repository hängt, wäre eine Falle.
</p>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || done}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : done ? (
<Check className="h-4 w-4" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
{done ? "Gesichert" : "Sichern"}
</button>
</div>
</form>
</Modal>
);
}
@@ -14,6 +14,7 @@ import {
ShieldCheck,
Trash2,
} from "lucide-react";
import { TotpSection, type TotpState } from "@/components/account/TotpSection";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
@@ -43,6 +44,7 @@ export interface AccountData {
passkeys: PasskeyEntry[];
sessions: SessionEntry[];
passkeysAvailable: boolean;
totp: TotpState;
}
type Notice = { ok: boolean; text: string } | null;
@@ -52,6 +54,7 @@ export function AccountManager({ data }: { data: AccountData }) {
<div className="space-y-6">
<ProfileSection data={data} />
<PasswordSection hasPassword={data.hasPassword} />
<TotpSection state={data.totp} hasPassword={data.hasPassword} />
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
<SessionSection sessions={data.sessions} />
</div>
+375
View File
@@ -0,0 +1,375 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Check,
Copy,
Download,
Loader2,
RefreshCw,
ShieldCheck,
ShieldOff,
Smartphone,
} from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
/**
* Zwei-Faktor-Anmeldung per Einmalkennwort.
*
* Die Einrichtung ist bewusst zweistufig: erst der QR-Code, dann ein Code zur
* Bestaetigung. Scharf wird der Schluessel erst danach – sonst waere jemand,
* der den QR-Code nicht scannen konnte, beim naechsten Anmelden ausgesperrt.
*/
export interface TotpState {
enabled: boolean;
enabledAt: string | Date | null;
recoveryCodesLeft: number;
}
type Notice = { ok: boolean; text: string } | null;
export function TotpSection({
state,
hasPassword,
}: {
state: TotpState;
hasPassword: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
// Einrichtung
const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null);
const [code, setCode] = useState("");
// Wird genau einmal angezeigt, direkt nach dem Erzeugen.
const [codes, setCodes] = useState<string[] | null>(null);
// Abschalten und Neuerzeugen verlangen beides das Passwort.
const [password, setPassword] = useState("");
const [confirming, setConfirming] = useState<"disable" | "recovery" | null>(null);
async function call(url: string, method: string, body?: unknown) {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
return payload;
}
async function start() {
setBusy(true);
setNotice(null);
try {
const payload = await call("/api/account/totp", "POST");
setSetup({ qr: payload.qr, secret: payload.secret });
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
async function confirm(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setNotice(null);
try {
const payload = await call("/api/account/totp", "PUT", { code });
setSetup(null);
setCode("");
setCodes(payload.recoveryCodes);
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
async function submitPassword(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setNotice(null);
try {
if (confirming === "disable") {
await call("/api/account/totp", "DELETE", { password });
setNotice({ ok: true, text: "Die Zwei-Faktor-Anmeldung ist abgeschaltet." });
setCodes(null);
} else {
const payload = await call("/api/account/totp/recovery", "POST", { password });
setCodes(payload.recoveryCodes);
setNotice({ ok: true, text: "Neue Codes erzeugt. Die alten gelten nicht mehr." });
}
setConfirming(null);
setPassword("");
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-xl",
state.enabled
? "bg-emerald-500/12 text-emerald-300"
: "bg-violet-500/12 text-violet-300",
)}
>
<ShieldCheck className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Zwei-Faktor-Anmeldung
</h2>
<p className="mt-1 text-sm text-slate-400">
{state.enabled
? `Aktiv seit ${formatDateTime(state.enabledAt)}. Nach dem Passwort fragt die Anmeldung nach einem Code.`
: "Ein zeitbasiertes Einmalkennwort zusätzlich zum Passwort — aus einer Authenticator-App."}
</p>
</div>
</div>
{!hasPassword ? (
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
Dieses Konto hat kein Passwort und meldet sich per Passkey an. Ein zweiter Faktor
käme dabei nie zum Einsatz — der Passkey ist bereits an dein Gerät gebunden.
</p>
) : null}
{/* Frisch erzeugte Wiederherstellungscodes, einmalige Anzeige. */}
{codes ? <RecoveryCodes codes={codes} onDone={() => setCodes(null)} /> : null}
{hasPassword && !state.enabled && !setup && !codes ? (
<button type="button" onClick={start} disabled={busy} className="btn btn-primary">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Smartphone className="h-4 w-4" />}
Einrichten
</button>
) : null}
{/* Schritt 1: QR-Code scannen, Schritt 2: Code bestaetigen. */}
{setup ? (
<form onSubmit={confirm} className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, kein Netzabruf */}
<img
src={setup.qr}
alt="QR-Code für die Authenticator-App"
width={160}
height={160}
className="shrink-0 rounded-xl bg-white p-2"
/>
<div className="min-w-0 space-y-2 text-sm text-slate-400">
<p>
Scanne den Code mit deiner Authenticator-App — etwa Aegis, 2FAS, Ente Auth
oder dem Passwortmanager deiner Wahl.
</p>
<p>Geht die Kamera nicht, trage den Schlüssel von Hand ein:</p>
<code className="block break-all rounded-lg border border-white/8 bg-black/30 px-3 py-2 font-mono text-xs text-violet-200">
{setup.secret}
</code>
</div>
</div>
<div className="max-w-xs">
<label className="field-label" htmlFor="totp-code">
Code aus der App
</label>
<input
id="totp-code"
className="field text-center text-lg tracking-[0.35em]"
value={code}
onChange={(event) => setCode(event.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={7}
required
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Aktivieren
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setSetup(null);
setCode("");
setNotice(null);
}}
>
Abbrechen
</button>
</div>
</form>
) : null}
{state.enabled && !setup ? (
<div className="space-y-3">
<p className="text-sm text-slate-400">
{state.recoveryCodesLeft > 0 ? (
<>
Noch <strong className="text-slate-200">{state.recoveryCodesLeft}</strong>{" "}
Wiederherstellungscodes übrig.
</>
) : (
<span className="text-amber-200">
Keine Wiederherstellungscodes mehr übrig. Ohne sie hilft bei einem verlorenen
Gerät nichts mehr — bitte neue erzeugen.
</span>
)}
</p>
{confirming ? (
<form onSubmit={submitPassword} className="max-w-sm space-y-3">
<div>
<label className="field-label" htmlFor="totp-password">
Passwort bestätigen
</label>
<input
id="totp-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="submit"
className={cn("btn", confirming === "disable" ? "btn-danger" : "btn-primary")}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{confirming === "disable" ? "Abschalten" : "Neue Codes erzeugen"}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setConfirming(null);
setPassword("");
}}
>
Abbrechen
</button>
</div>
</form>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-ghost"
onClick={() => setConfirming("recovery")}
>
<RefreshCw className="h-4 w-4" />
Neue Wiederherstellungscodes
</button>
<button
type="button"
className="btn btn-ghost text-rose-200"
onClick={() => setConfirming("disable")}
>
<ShieldOff className="h-4 w-4" />
Abschalten
</button>
</div>
)}
</div>
) : null}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
/**
* Die Codes werden nur dieses eine Mal gezeigt. Deshalb Kopieren und
* Herunterladen direkt daneben – und ein Knopf, der das Wegklicken zu einer
* bewussten Handlung macht.
*/
function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) {
const [copied, setCopied] = useState(false);
const text = codes.join("\n");
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Ohne Zwischenablage-Recht bleibt Markieren von Hand.
}
}
function download() {
const url = URL.createObjectURL(new Blob([`${text}\n`], { type: "text/plain" }));
const link = document.createElement("a");
link.href = url;
link.download = "nebula-wiederherstellungscodes.txt";
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/8 p-4">
<h3 className="text-sm font-semibold text-amber-100">Wiederherstellungscodes</h3>
<p className="mt-1 text-sm text-amber-200/80">
Jeder Code gilt einmal. Bewahre sie an einem sicheren Ort auf — sie sind der einzige
Weg zurück, wenn dein Gerät verloren geht. <strong>Sie werden nur jetzt angezeigt.</strong>
</p>
<ul className="mt-3 grid grid-cols-2 gap-1.5 font-mono text-sm text-amber-50 sm:grid-cols-2">
{codes.map((entry) => (
<li key={entry} className="rounded-md bg-black/25 px-2.5 py-1.5">
{entry}
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={copy} className="btn btn-ghost text-xs">
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
<button type="button" onClick={download} className="btn btn-ghost text-xs">
<Download className="h-3.5 w-3.5" />
Herunterladen
</button>
<button type="button" onClick={onDone} className="btn btn-ghost text-xs">
Ich habe sie gesichert
</button>
</div>
</div>
);
}
+97 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startAuthentication } from "@simplewebauthn/browser";
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react";
import { Fingerprint, KeyRound, Loader2, LogIn, ShieldCheck } from "lucide-react";
export function LoginForm({
passkeysAvailable,
@@ -18,8 +18,11 @@ export function LoginForm({
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState<"password" | "passkey" | null>(null);
const [busy, setBusy] = useState<"password" | "passkey" | "code" | null>(null);
const [error, setError] = useState<string | null>(null);
/** Passwort stimmt, jetzt fehlt der zweite Faktor. */
const [mfa, setMfa] = useState(false);
const [code, setCode] = useState("");
function done() {
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
@@ -39,6 +42,14 @@ export function LoginForm({
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
// Zweiter Faktor eingerichtet: das Passwort allein reicht nicht.
if (payload.mfaRequired) {
setMfa(true);
setPassword("");
setBusy(null);
return;
}
done();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
@@ -46,6 +57,26 @@ export function LoginForm({
}
}
async function submitCode(event: React.FormEvent) {
event.preventDefault();
setBusy("code");
setError(null);
try {
const response = await fetch("/api/auth/mfa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Der Code stimmt nicht.");
done();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
setCode("");
setBusy(null);
}
}
async function loginWithPasskey() {
setBusy("passkey");
setError(null);
@@ -82,6 +113,70 @@ export function LoginForm({
}
}
// Beim zweiten Schritt bleibt nur noch der Code stehen. Benutzername und
// Passwort daneben einzublenden wuerde nur zum erneuten Absenden verleiten.
if (mfa) {
return (
<form onSubmit={submitCode} className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Bitte den sechsstelligen Code aus deiner Authenticator-App eingeben. Kein
Zugriff darauf? Ein Wiederherstellungscode geht auch.
</span>
</div>
<div>
<label className="field-label" htmlFor="login-code">
Code
</label>
<input
id="login-code"
className="field text-center text-lg tracking-[0.35em]"
value={code}
onChange={(event) => setCode(event.target.value)}
// Nicht type="number": das schluckt fuehrende Nullen und die
// Bindestriche der Wiederherstellungscodes.
inputMode="text"
autoComplete="one-time-code"
autoCapitalize="none"
spellCheck={false}
maxLength={40}
required
autoFocus
/>
</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}
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
{busy === "code" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
Bestätigen
</button>
<button
type="button"
onClick={() => {
setMfa(false);
setCode("");
setError(null);
}}
className="w-full text-center text-sm text-slate-500 hover:text-slate-300"
>
Abbrechen
</button>
</form>
);
}
return (
<div className="space-y-5">
{passkeysAvailable ? (
+13 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +70,22 @@ export function TaskCard({
{task.title}
</p>
{(task.description || task.dueDate || task.labels.length > 0) && (
{(task.description || task.dueDate || task.recurrence || 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.recurrence ? (
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`Wiederholt sich: ${RECURRENCE_META[task.recurrence].label}`}
>
<Repeat className="h-2.5 w-2.5" />
{RECURRENCE_META[task.recurrence].short}
</span>
) : null}
{task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
+31 -2
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import type { Recurrence, Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
@@ -36,6 +36,7 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,6 +49,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null);
}, [open, task, initialStatus]);
@@ -64,6 +66,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
recurrence: recurrence || null,
};
try {
@@ -194,6 +197,32 @@ export function TaskDialog({
placeholder="bug, ui"
/>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="task-recurrence">
Wiederholung
</label>
<select
id="task-recurrence"
className="field"
value={recurrence}
onChange={(event) => setRecurrence(event.target.value)}
>
<option value="">– einmalig –</option>
{(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => (
<option key={key} value={key}>
{RECURRENCE_META[key].label}
</option>
))}
</select>
{recurrence ? (
<p className="mt-1 text-xs text-slate-500">
Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet
wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe
bleibt also auf ihrem Wochentag.
</p>
) : null}
</div>
</div>
{error ? (
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { describeFields, describeStatus, shorten } from "./activity";
describe("describeFields", () => {
it("nennt ein einzelnes Feld beim Namen", () => {
expect(describeFields(["name"])).toBe("Name geändert");
});
it("zaehlt zwei und drei Felder auf", () => {
expect(describeFields(["name", "tags"])).toBe("Name und Tags geändert");
expect(describeFields(["name", "tags", "priority"])).toBe(
"Name, Tags und Priorität geändert",
);
});
it("fasst ab vier Feldern zusammen, statt eine Litanei zu bilden", () => {
expect(describeFields(["name", "tags", "priority", "accent"])).toBe("4 Felder geändert");
});
it("zaehlt zusammengehoerende Felder nur einmal", () => {
// repoOwner und repoName sind fuer den Leser eine einzige Aenderung.
expect(describeFields(["repoOwner", "repoName"])).toBe("Repository geändert");
});
it("liefert null, wenn nichts Nennenswertes dabei war", () => {
// slug wird nur mitgeschrieben, wenn sich der Name aendert – der Name
// steht dann ohnehin schon in der Liste.
expect(describeFields([])).toBeNull();
expect(describeFields(["slug", "updatedAt"])).toBeNull();
});
});
describe("describeStatus", () => {
it("uebersetzt beide Seiten in die Anzeigenamen", () => {
expect(describeStatus("IDEA", "IN_DEVELOPMENT")).toBe("Status: Idee → In Entwicklung");
});
it("laesst unbekannte Werte stehen, statt sie zu verschlucken", () => {
expect(describeStatus("IDEA", "PHANTASIE")).toBe("Status: Idee → PHANTASIE");
});
});
describe("shorten", () => {
it("laesst Kurzes unangetastet", () => {
expect(shorten("Kurzer Titel")).toBe("Kurzer Titel");
});
it("kuerzt auf die vorgegebene Laenge einschliesslich Auslassungszeichen", () => {
const gekuerzt = shorten("x".repeat(100), 20);
expect(gekuerzt).toHaveLength(20);
expect(gekuerzt.endsWith("…")).toBe(true);
});
it("macht aus Zeilenumbruechen eine Zeile", () => {
expect(shorten("Erste Zeile\n\n zweite Zeile ")).toBe("Erste Zeile zweite Zeile");
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { ActivityKind, Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { STATUS_META } from "@/lib/status";
/**
* Verlauf je Projekt: was ist wann passiert.
*
* Der Einzeiler wird beim Schreiben formuliert und nicht erst beim Anzeigen.
* Das ist Absicht: ein Eintrag soll auch dann noch lesbar sein, wenn es die
* Notiz oder die Aufgabe, um die es ging, laengst nicht mehr gibt.
*/
/** Wie viele Eintraege je Projekt aufgehoben werden. */
export const ACTIVITY_LIMIT = 200;
type Entry = {
projectId: string;
/** null = die Anwendung selbst, etwa der Zeitgeber beim Abgleich. */
userId?: string | null;
kind: ActivityKind;
summary: string;
meta?: Prisma.InputJsonValue;
};
/**
* Haelt einen Vorgang fest. Wirft nie: der Verlauf ist Beiwerk, und daran
* darf die eigentliche Aenderung nicht scheitern.
*/
export async function recordActivity(entry: Entry): Promise<void> {
try {
await prisma.activity.create({
data: {
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
},
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
/** Mehrere Vorgaenge auf einmal – ein Rundgang statt vieler. */
export async function recordActivities(entries: Entry[]): Promise<void> {
if (entries.length === 0) return;
try {
await prisma.activity.createMany({
data: entries.map((entry) => ({
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
})),
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
// ── Formulierungshilfen ──────────────────────────────────────
const FIELD_LABELS: Record<string, string> = {
name: "Name",
summary: "Kurzbeschreibung",
description: "Beschreibung",
priority: "Priorität",
progress: "Fortschritt",
accent: "Farbe",
tags: "Tags",
favorite: "Favorit",
autoSync: "Automatischer Abgleich",
progressFromTasks: "Fortschritt aus Aufgaben",
connectionId: "Git-Verbindung",
repoUrl: "Repository",
repoOwner: "Repository",
repoName: "Repository",
defaultBranch: "Standard-Branch",
};
/**
* Aus geaenderten Feldnamen einen lesbaren Satz machen: "Name und Tags
* geändert". Doppelte Bezeichnungen fallen weg – wer repoOwner und repoName
* zusammen setzt, hat einmal das Repository geaendert, nicht zweimal.
*/
export function describeFields(fields: string[]): string | null {
const labels: string[] = [];
for (const field of fields) {
const label = FIELD_LABELS[field];
if (label && !labels.includes(label)) labels.push(label);
}
if (labels.length === 0) return null;
if (labels.length === 1) return `${labels[0]} geändert`;
// Ab vier Feldern wird die Aufzaehlung laenger als sie nuetzt.
if (labels.length > 3) return `${labels.length} Felder geändert`;
return `${labels.slice(0, -1).join(", ")} und ${labels.at(-1)} geändert`;
}
/** "Idee → In Entwicklung" */
export function describeStatus(from: string, to: string): string {
const label = (value: string) =>
STATUS_META[value as keyof typeof STATUS_META]?.label ?? value;
return `Status: ${label(from)} → ${label(to)}`;
}
/** Kuerzt lange Titel, damit ein Eintrag eine Zeile bleibt. */
export function shorten(text: string, max = 70): string {
const clean = text.replace(/\s+/g, " ").trim();
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
}
/**
* Stutzt den Verlauf auf die neuesten Eintraege je Projekt zurueck. Laeuft
* im taeglichen Durchgang des Zeitgebers mit – bei jedem Schreiben zu
* pruefen waere eine zusaetzliche Abfrage fuer nichts.
*/
export async function pruneActivities(): Promise<number> {
const rows = await prisma.$queryRaw<{ count: bigint }[]>`
WITH nummeriert AS (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "projectId" ORDER BY "createdAt" DESC, "id" DESC
) AS rang
FROM "Activity"
), geloescht AS (
DELETE FROM "Activity"
WHERE "id" IN (SELECT "id" FROM nummeriert WHERE rang > ${ACTIVITY_LIMIT})
RETURNING 1
)
SELECT COUNT(*)::bigint AS count FROM geloescht
`;
return Number(rows[0]?.count ?? 0);
}
+128
View File
@@ -0,0 +1,128 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextResponse } from "next/server";
import { COOKIES_SECURE, MFA_COOKIE, MFA_TTL_MS } from "@/lib/config";
import { tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
/**
* Der Zwischenzustand zwischen Passwort und zweitem Faktor.
*
* Bewusst nicht als halbfertige Sitzung: eine Sitzungszeile mit einem
* "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen
* Filter irgendwo doch als Anmeldung durchgeht. Ein eigenes Modell kann
* dieses Versehen nicht passieren.
*/
/** Nach so vielen Fehlversuchen ist der Vorgang verbrannt. */
export const MFA_MAX_ATTEMPTS = 5;
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("base64url");
}
export async function createPendingLogin(userId: string): Promise<string> {
const token = randomBytes(32).toString("base64url");
// Aeltere Vorgaenge desselben Kontos verwerfen: es kann nur einen geben,
// sonst sammeln sich bei jedem Anlauf neue Rateversuche an.
await prisma.pendingLogin.deleteMany({
where: { OR: [{ userId }, { expiresAt: { lt: new Date() } }] },
});
await prisma.pendingLogin.create({
data: {
userId,
tokenHash: hashToken(token),
expiresAt: new Date(Date.now() + MFA_TTL_MS),
},
});
return token;
}
export function setMfaCookie(response: NextResponse, token: string) {
response.cookies.set({
name: MFA_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: Math.floor(MFA_TTL_MS / 1000),
});
}
export function clearMfaCookie(response: NextResponse) {
response.cookies.set({
name: MFA_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
});
}
export interface PendingLoginState {
id: string;
attempts: number;
user: {
id: string;
username: string;
role: "ADMIN" | "USER";
isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null;
};
}
/** Liest den laufenden Vorgang aus dem Cookie. */
export async function readPendingLogin(): Promise<PendingLoginState | null> {
const store = await cookies();
const token = store.get(MFA_COOKIE)?.value;
if (!token) return null;
const pending = await prisma.pendingLogin.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!pending) return null;
if (pending.expiresAt.getTime() <= Date.now()) {
await prisma.pendingLogin.delete({ where: { id: pending.id } }).catch(() => undefined);
return null;
}
return {
id: pending.id,
attempts: pending.attempts,
user: {
id: pending.user.id,
username: pending.user.username,
role: pending.user.role,
isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
},
};
}
/** Zaehlt einen Fehlversuch. Gibt zurueck, ob noch Versuche offen sind. */
export async function noteMfaFailure(id: string): Promise<boolean> {
const updated = await prisma.pendingLogin.update({
where: { id },
data: { attempts: { increment: 1 } },
select: { attempts: true },
});
if (updated.attempts >= MFA_MAX_ATTEMPTS) {
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
return false;
}
return true;
}
export async function discardPendingLogin(id: string): Promise<void> {
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { hashCode, normalizeCode } from "./recovery";
describe("normalizeCode", () => {
it("setzt den Bindestrich, wenn er beim Abtippen fehlt", () => {
expect(normalizeCode("a7k2mp9xqd")).toBe("a7k2m-p9xqd");
});
it("nimmt Grossschreibung und Leerzeichen hin", () => {
// Aus einem Ausdruck abgeschrieben sieht ein Code selten so aus wie
// beim Erzeugen.
expect(normalizeCode("A7K2M-P9XQD")).toBe("a7k2m-p9xqd");
expect(normalizeCode(" a7k2m p9xqd ")).toBe("a7k2m-p9xqd");
expect(normalizeCode("a7k2m—p9xqd")).toBe("a7k2m-p9xqd");
});
it("laesst zu kurze Eingaben unveraendert, statt sie zurechtzubiegen", () => {
expect(normalizeCode("abc")).toBe("abc");
});
});
describe("hashCode", () => {
it("bildet Schreibweisen desselben Codes auf denselben Hash ab", () => {
expect(hashCode("A7K2M-P9XQD")).toBe(hashCode("a7k2mp9xqd"));
});
it("unterscheidet verschiedene Codes", () => {
expect(hashCode("a7k2m-p9xqd")).not.toBe(hashCode("a7k2m-p9xqe"));
});
it("gibt den Code nicht preis", () => {
expect(hashCode("a7k2m-p9xqd")).not.toContain("a7k2m");
});
});
+100
View File
@@ -0,0 +1,100 @@
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Wiederherstellungscodes fuer den Fall, dass der Authenticator verloren geht.
*
* Ohne sie waere die Zwei-Faktor-Anmeldung eine Falle: ein neues Handy, und
* das Konto ist zu. Ein Administrator kann zwar aushelfen – im
* Einzelbenutzerbetrieb ist der Administrator aber genau die ausgesperrte
* Person.
*/
/** Wie viele Codes bei der Einrichtung erzeugt werden. */
export const RECOVERY_CODE_COUNT = 10;
/**
* Alphabet ohne die Paare, die man beim Abschreiben verwechselt: kein O/0,
* kein I/l/1, kein B/8. Die Codes werden von Hand aus einem Ausdruck oder
* Passwortmanager uebertragen, oft unter Zeitdruck.
*/
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
const GROUP = 5;
/** Ein Code der Form "a7k2m-p9xqd" – rund 50 Bit Zufall. */
function generateCode(): string {
const zeichen = Array.from(
{ length: GROUP * 2 },
() => ALPHABET[randomInt(ALPHABET.length)],
).join("");
return `${zeichen.slice(0, GROUP)}-${zeichen.slice(GROUP)}`;
}
/** Vereinheitlicht Eingaben: Grossschreibung und fehlender Bindestrich. */
export function normalizeCode(input: string): string {
const clean = input.toLowerCase().replace(/[^a-z0-9]/g, "");
if (clean.length !== GROUP * 2) return clean;
return `${clean.slice(0, GROUP)}-${clean.slice(GROUP)}`;
}
export function hashCode(code: string): string {
return createHash("sha256").update(normalizeCode(code)).digest("base64url");
}
/**
* Erzeugt einen frischen Satz und verwirft den alten. Gibt die Codes im
* Klartext zurueck – das ist die einzige Gelegenheit, sie zu sehen.
*/
export async function issueRecoveryCodes(userId: string): Promise<string[]> {
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateCode);
await prisma.$transaction([
prisma.recoveryCode.deleteMany({ where: { userId } }),
prisma.recoveryCode.createMany({
data: codes.map((code) => ({ userId, codeHash: hashCode(code) })),
}),
]);
return codes;
}
/**
* Loest einen Code ein. Ein Code gilt genau einmal; verbraucht wird er auch
* dann, wenn die Anmeldung danach aus anderem Grund scheitert – lieber ein
* Code zu viel verbraucht als einer, der zweimal geht.
*/
export async function redeemRecoveryCode(userId: string, input: string): Promise<boolean> {
const eingabe = normalizeCode(input);
if (eingabe.length < GROUP * 2) return false;
const offen = await prisma.recoveryCode.findMany({
where: { userId, usedAt: null },
select: { id: true, codeHash: true },
});
const gesucht = Buffer.from(hashCode(eingabe), "utf8");
let treffer: string | null = null;
for (const code of offen) {
const vorhanden = Buffer.from(code.codeHash, "utf8");
// Nicht abbrechen, sobald es passt: die Laufzeit soll nicht verraten,
// an welcher Stelle der Liste der Code stand.
if (vorhanden.length === gesucht.length && timingSafeEqual(vorhanden, gesucht)) {
treffer = code.id;
}
}
if (!treffer) return false;
// updateMany mit der Bedingung "noch unbenutzt": zwei gleichzeitige
// Versuche mit demselben Code koennen so nicht beide durchkommen.
const result = await prisma.recoveryCode.updateMany({
where: { id: treffer, usedAt: null },
data: { usedAt: new Date() },
});
return result.count === 1;
}
/** Wie viele Codes noch ungenutzt sind – fuer den Hinweis im Konto. */
export async function countUnusedRecoveryCodes(userId: string): Promise<number> {
return prisma.recoveryCode.count({ where: { userId, usedAt: null } });
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
TOTP_PERIOD,
base32Decode,
base32Encode,
counterFor,
generateTotpSecret,
groupSecret,
otpauthUri,
totpCode,
verifyTotp,
} from "./totp";
describe("Base32", () => {
// Testvektoren aus RFC 4648, Abschnitt 10 – ohne die Auffuellzeichen,
// die Authenticator-Apps nicht erwarten.
it.each([
["", ""],
["f", "MY"],
["fo", "MZXQ"],
["foo", "MZXW6"],
["foob", "MZXW6YQ"],
["fooba", "MZXW6YTB"],
["foobar", "MZXW6YTBOI"],
])("kodiert %j als %j", (klartext, erwartet) => {
expect(base32Encode(Buffer.from(klartext, "utf8"))).toBe(erwartet);
});
it("dekodiert zurueck, auch mit Leerzeichen und Auffuellzeichen", () => {
expect(base32Decode("MZXW6YTBOI").toString("utf8")).toBe("foobar");
expect(base32Decode("mzxw 6ytb oi").toString("utf8")).toBe("foobar");
expect(base32Decode("MZXW6YTBOI======").toString("utf8")).toBe("foobar");
});
it("weist fremde Zeichen ab, statt still Unsinn zu liefern", () => {
// 0, 1 und 8 gehoeren nicht zum Alphabet – gerade sie werden aber gern
// mit O, I und B verwechselt.
expect(() => base32Decode("MZXW0YTB")).toThrow();
});
});
describe("TOTP nach RFC 6238", () => {
// Der Schluessel aus Anhang B des RFC: die ASCII-Ziffern 1234567890,
// zweimal hintereinander.
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
/**
* Der RFC nennt achtstellige Codes. Sechs Stellen sind davon die letzten
* sechs – gerechnet wird modulo 10^6 statt 10^8.
*/
it.each([
[59, "287082"],
[1111111109, "081804"],
[1111111111, "050471"],
[1234567890, "005924"],
[2000000000, "279037"],
[20000000000, "353130"],
])("liefert zum Zeitpunkt %i den Code %s", (sekunden, erwartet) => {
const counter = Math.floor(sekunden / TOTP_PERIOD);
expect(totpCode(SECRET, counter)).toBe(erwartet);
});
it("rechnet Zeitpunkte in Fenster um", () => {
expect(counterFor(new Date(59_000))).toBe(1);
expect(counterFor(new Date(1111111109_000))).toBe(37037036);
});
});
describe("verifyTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
it("nimmt den Code des aktuellen Fensters an", () => {
expect(verifyTotp(SECRET, "081804", JETZT)).toBe(true);
});
it("nimmt auch das vorige und das naechste Fenster an", () => {
// Eine um bis zu 30 Sekunden falsch gehende Uhr soll niemanden aussperren.
const vorher = new Date(JETZT.getTime() - TOTP_PERIOD * 1000);
const nachher = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", vorher)).toBe(true);
expect(verifyTotp(SECRET, "081804", nachher)).toBe(true);
});
it("lehnt ein Fenster weiter ab", () => {
const zuFrueh = new Date(JETZT.getTime() - 2 * TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", zuFrueh)).toBe(false);
});
it("lehnt falsche Codes und Unfug ab", () => {
expect(verifyTotp(SECRET, "000000", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "81804", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "abcdef", JETZT)).toBe(false);
// Fuehrende Nullen duerfen nicht als Zahl verlorengehen.
expect(verifyTotp(SECRET, "81804 ", JETZT)).toBe(false);
});
it("sieht ueber Leerzeichen in der Eingabe hinweg", () => {
expect(verifyTotp(SECRET, "081 804", JETZT)).toBe(true);
});
});
describe("Einrichtung", () => {
it("erzeugt Schluessel, die sich unterscheiden und lang genug sind", () => {
const a = generateTotpSecret();
const b = generateTotpSecret();
expect(a).not.toBe(b);
// 20 Byte ergeben 32 Base32-Zeichen.
expect(a).toHaveLength(32);
expect(base32Decode(a)).toHaveLength(20);
});
it("baut eine otpauth-URI mit Aussteller im Label und im Parameter", () => {
const uri = otpauthUri({ secret: "ABCD", account: "chefin", issuer: "Nebula" });
expect(uri).toContain("otpauth://totp/Nebula%3Achefin?");
expect(uri).toContain("secret=ABCD");
expect(uri).toContain("issuer=Nebula");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
});
it("gruppiert den Schluessel zum Abtippen", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
});
});
+158
View File
@@ -0,0 +1,158 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Zeitbasierte Einmalkennwoerter nach RFC 6238 (TOTP über HMAC-SHA1).
*
* Selbst gerechnet statt als Abhaengigkeit: es sind wenige Zeilen, das
* Verfahren steht seit 2011 fest, und der RFC bringt Testvektoren mit –
* die Richtigkeit laesst sich also beweisen statt nur behaupten
* (siehe totp.test.ts).
*
* SHA-1 ist hier kein Mangel. Es geht nicht um Kollisionsresistenz, sondern
* um HMAC mit einem geheimen Schluessel, und jede Authenticator-App der Welt
* erwartet genau diese Voreinstellung.
*/
/** Stellenzahl des Codes. */
export const TOTP_DIGITS = 6;
/** Laenge eines Zeitfensters in Sekunden. */
export const TOTP_PERIOD = 30;
/**
* Wie viele Fenster in jede Richtung zusaetzlich gelten. Eines deckt eine um
* bis zu 30 Sekunden falsch gehende Uhr ab – mehr wuerde den Zeitraum
* unnoetig verbreitern, in dem ein abgefangener Code noch taugt.
*/
export const TOTP_WINDOW = 1;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** Base32 nach RFC 4648, ohne Auffuellzeichen – so erwarten es die Apps. */
export function base32Encode(data: Buffer): string {
let bits = 0;
let value = 0;
let output = "";
for (const byte of data) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input: string): Buffer {
// Leerzeichen und Auffuellzeichen grosszuegig hinnehmen: Benutzer tippen
// den Schluessel gelegentlich aus einer Anzeige in Vierergruppen ab.
const clean = input.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, "");
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of clean) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) throw new Error("Ungueltiges Zeichen im Base32-Schluessel");
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/**
* Neuer Schluessel. 20 Byte entsprechen der Empfehlung des RFC fuer
* HMAC-SHA1 und ergeben 32 Base32-Zeichen.
*/
export function generateTotpSecret(): string {
return base32Encode(randomBytes(20));
}
/** Der Code fuer ein bestimmtes Zeitfenster. */
export function totpCode(secret: string, counter: number): string {
const key = base32Decode(secret);
// Zaehler als 8-Byte-Zahl in Big-Endian. BigInt, weil er theoretisch
// ueber 2^32 hinausgeht – praktisch erst in einigen tausend Jahren, aber
// die Bitschieberei mit Zahlen waere hier ohnehin heikel.
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", key).update(buffer).digest();
// Dynamic Truncation nach RFC 4226, Abschnitt 5.3.
const offset = digest[digest.length - 1] & 0x0f;
const binary =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
}
/** Das Zeitfenster, in dem ein Zeitpunkt liegt. */
export function counterFor(at: Date = new Date()): number {
return Math.floor(at.getTime() / 1000 / TOTP_PERIOD);
}
/**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert.
*/
export function verifyTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): boolean {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
}
function constantEquals(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
/**
* Die URI, die als QR-Code angezeigt wird. Das Label enthaelt sowohl den
* Aussteller als auch das Konto – so schreiben es die Authenticator-Apps
* vor, sonst steht in der Liste nur ein nackter Benutzername.
*/
export function otpauthUri(options: {
secret: string;
account: string;
issuer: string;
}): string {
const label = `${options.issuer}:${options.account}`;
const query = new URLSearchParams({
secret: options.secret,
issuer: options.issuer,
algorithm: "SHA1",
digits: String(TOTP_DIGITS),
period: String(TOTP_PERIOD),
});
return `otpauth://totp/${encodeURIComponent(label)}?${query.toString()}`;
}
/** Der Schluessel in Vierergruppen – zum Abtippen, wenn die Kamera streikt. */
export function groupSecret(secret: string): string {
return secret.replace(/(.{4})/g, "$1 ").trim();
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { CHANGELOG, KIND_META } from "./changelog";
import { APP_VERSION } from "./config";
/**
* Der Verlauf und die Versionsnummer sind zwei Angaben, die zusammenpassen
* muessen. Genau daran ist dieses Projekt schon einmal gescheitert: die
* Version stand in der Fusszeile und in der package.json und zeigte
* verschiedene Werte.
*/
describe("Aenderungsverlauf", () => {
it("beginnt mit der Fassung, die auch installiert ist", () => {
expect(CHANGELOG[0].version).toBe(APP_VERSION);
});
it("nennt jede Fassung nur einmal", () => {
const versionen = CHANGELOG.map((release) => release.version);
expect(new Set(versionen).size).toBe(versionen.length);
});
it("zaehlt absteigend", () => {
const zahl = (version: string) =>
version.split(".").reduce((sum, teil) => sum * 1000 + Number(teil), 0);
for (let index = 1; index < CHANGELOG.length; index += 1) {
expect(
zahl(CHANGELOG[index - 1].version),
`${CHANGELOG[index - 1].version} muss ueber ${CHANGELOG[index].version} stehen`,
).toBeGreaterThan(zahl(CHANGELOG[index].version));
}
});
it("hat zu jeder Fassung ein Datum, eine Zeile und mindestens eine Aenderung", () => {
for (const release of CHANGELOG) {
expect(release.version, "Version im Format x.y.z").toMatch(/^\d+\.\d+\.\d+$/);
expect(Number.isNaN(Date.parse(release.date)), `Datum von ${release.version}`).toBe(false);
expect(release.headline.length, `Zeile zu ${release.version}`).toBeGreaterThan(0);
expect(release.changes.length, `Eintraege zu ${release.version}`).toBeGreaterThan(0);
}
});
it("kennt zu jeder Art eine Beschriftung", () => {
for (const release of CHANGELOG) {
for (const change of release.changes) {
expect(KIND_META[change.kind], `${change.kind} in v${release.version}`).toBeDefined();
expect(change.text.trim().length).toBeGreaterThan(0);
}
}
});
});
+398
View File
@@ -0,0 +1,398 @@
/**
* Was sich von Fassung zu Fassung geaendert hat.
*
* Bewusst hier und nicht als CHANGELOG.md: die Liste wird in der Anwendung
* angezeigt, und zwei Quellen liefen prompt auseinander – wie es der
* Versionsnummer schon einmal passiert ist, die eine Zeit lang in der
* Fusszeile und in der package.json stand und verschiedene Werte zeigte.
*
* Die oberste Fassung ist die aktuelle. Neue Eintraege kommen nach vorne.
*/
export type ChangeKind = "neu" | "besser" | "behoben";
export interface ChangeEntry {
kind: ChangeKind;
text: string;
}
export interface Release {
version: string;
/** ISO-Datum, ohne Uhrzeit. */
date: string;
/** Ein Satz darueber, worum es in dieser Fassung ging. */
headline: string;
changes: ChangeEntry[];
}
export const KIND_META: Record<ChangeKind, { label: string; className: string }> = {
neu: {
label: "Neu",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
besser: {
label: "Besser",
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
},
behoben: {
label: "Behoben",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
export const CHANGELOG: Release[] = [
{
version: "1.4.0",
date: "2026-07-26",
headline: "Worum ging es hier nochmal?",
changes: [
{
kind: "neu",
text:
"README-Vorschau auf der Projektseite. Erspart den Wechsel ins Git, nur um zu " +
"sehen, worum ein halbvergessenes Projekt eigentlich ging.",
},
{
kind: "besser",
text:
"Relative Links im README zeigen dorthin, wo sie hingehören — aufgelöst gegen " +
"das Verzeichnis der Datei im Repository.",
},
{
kind: "besser",
text:
"Bilder im README werden bewusst nicht geladen, sondern als anklickbarer " +
"Verweis gezeigt. Die Content-Security-Policy dafür zu öffnen hieße, jedem " +
"fremden README das Nachladen beliebiger Bilder zu erlauben — auch Zählpixeln.",
},
],
},
{
version: "1.3.0",
date: "2026-07-26",
headline: "Was sich wiederholt, und was besser lesbar ist.",
changes: [
{
kind: "neu",
text:
"Wiederkehrende Aufgaben: täglich, wöchentlich, alle zwei Wochen oder monatlich. " +
"Wandert die Aufgabe nach „Erledigt“, entsteht die nächste von selbst.",
},
{
kind: "behoben",
text:
"Gedämpfter Text erreichte auf dem dunklen Untergrund nur 3,8 statt der " +
"geforderten 4,5 zu 1 — an 26 Stellen. Beide Stufen sind jetzt aufgehellt, " +
"der Abstand zwischen ihnen bleibt sichtbar.",
},
{
kind: "besser",
text:
"Die Barrierefreiheit ist mit axe-core über zehn Seitenzustände geprüft, " +
"einschließlich der Dialoge. Keine Verstöße mehr gegen WCAG 2.1 AA.",
},
{
kind: "besser",
text:
"Der Folgetermin rechnet vom Fälligkeitsdatum, nicht vom Abhaken — eine " +
"wöchentliche Aufgabe bleibt auf ihrem Wochentag. Lange Liegengebliebenes " +
"rutscht so weit vor, bis der Termin in der Zukunft liegt.",
},
],
},
{
version: "1.2.0",
date: "2026-07-26",
headline: "Ein Jahr auf einen Blick, und ein Start ohne Leerlauf.",
changes: [
{
kind: "neu",
text:
"Commit-Heatmap auf der Projektseite: ein Feld je Tag über bis zu ein Jahr, " +
"mit Farbskala und Datum im Tooltip.",
},
{
kind: "neu",
text:
"Projektvorlagen. Ein bewährtes Projekt über „Als Vorlage sichern“ festhalten — " +
"Tags, Notizen und offene Aufgaben stehen dann beim nächsten Projekt bereit.",
},
{
kind: "besser",
text:
"Die Heatmap-Daten kommen höchstens einmal am Tag und getrennt vom übrigen " +
"Abgleich. Bei GitHub genügt ein Aufruf der Jahresstatistik, bei GitLab und " +
"Gitea werden bis zu fünf Seiten Commits geholt.",
},
{
kind: "besser",
text:
"Reicht die Historie nicht ein Jahr zurück, beginnt die Heatmap beim ältesten " +
"bekannten Commit — leere Felder für unbekannte Zeiträume wären eine Lüge.",
},
],
},
{
version: "1.1.0",
date: "2026-07-26",
headline: "Das Brett wird zur Arbeitsfläche.",
changes: [
{
kind: "neu",
text:
"Freie Reihenfolge im Brett: Karten lassen sich innerhalb einer Spalte anordnen, " +
"die Position bleibt erhalten.",
},
{
kind: "neu",
text:
"Mehrfachauswahl. Über die Kästchen mehrere Projekte wählen und Status, Tags oder " +
"Favoriten in einem Zug ändern — oder alle auf einmal löschen.",
},
{
kind: "neu",
text:
"Als App installierbar: Nebula bringt ein Web-Manifest und eigene Symbole mit und " +
"lässt sich auf dem Handy zum Startbildschirm hinzufügen.",
},
{
kind: "neu",
text: "Dieser Änderungsverlauf, erreichbar über die Versionsnummer in der Fußzeile.",
},
{
kind: "besser",
text:
"Ansicht, Sortierung und der Archivschalter überdauern das Neuladen. Wer sein " +
"Brett von Hand anordnet, will es beim nächsten Aufruf auch sehen.",
},
{
kind: "behoben",
text:
"Neu angelegte, importierte und verschobene Projekte bekamen keine Position und " +
"stapelten sich im Brett alle auf demselben Platz.",
},
{
kind: "besser",
text:
"Umsortieren im Brett fasst das Änderungsdatum nicht mehr an. Vorher schob ein " +
"Aufräumen sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.",
},
{
kind: "besser",
text:
"Die Sortierung im Kopf ist im Brett abgeschaltet — dort zählt die Anordnung von Hand. " +
"Sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.",
},
{
kind: "besser",
text:
"Prisma liest seine Einstellungen aus prisma.config.ts statt aus der package.json. " +
"Damit ist die Warnung vor Prisma 7 weg.",
},
],
},
{
version: "1.0.0",
date: "2026-07-26",
headline: "Der Funktionsumfang ist beisammen.",
changes: [
{
kind: "neu",
text:
"Aktivitätsverlauf je Projekt: Statuswechsel, Notizen, Aufgaben und neue Commits " +
"in einer Zeitleiste.",
},
{
kind: "neu",
text:
"Zwei-Faktor-Anmeldung per TOTP, mit QR-Code zum Einrichten und zehn " +
"Wiederherstellungscodes für den Fall, dass das Gerät verloren geht.",
},
{
kind: "neu",
text:
"Mehrere Repositories je Projekt — Frontend und Backend gehören in eine Karte. " +
"Jedes mit eigenen Kennzahlen, eigener CI und eigenem Abgleich.",
},
{
kind: "besser",
text:
"Benachrichtigungen merken sich den gemeldeten Zustand je Repository. Vorher " +
"verdeckte ein rotes Frontend die rote Pipeline des Backends.",
},
{
kind: "besser",
text:
"Stillstand bemisst sich am jüngsten Commit über alle Repositories: an einem " +
"Vorhaben wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.",
},
{
kind: "behoben",
text:
"Der Verlauf lieferte ohne ausdrückliche Angabe genau einen Eintrag statt dreißig " +
"— Number(null) ist 0, nicht „nicht angegeben“.",
},
{
kind: "behoben",
text:
"Die Issue-Übernahme antwortete mit „nicht gefunden“, obwohl gar kein bestimmtes " +
"Repository angefragt war.",
},
],
},
{
version: "0.9.0",
date: "2026-07-26",
headline: "Schneller ans Ziel.",
changes: [
{
kind: "neu",
text:
"Schnellsuche mit Strg+K: Projekte, Notizen und Bereiche, bedienbar allein mit " +
"der Tastatur.",
},
{
kind: "neu",
text:
"Offene Git-Issues als Aufgaben übernehmen, mit Titel, Labels und Link zurück. " +
"Bereits übernommene werden übersprungen.",
},
],
},
{
version: "0.8.0",
date: "2026-07-26",
headline: "Der sanfte Stups.",
changes: [
{
kind: "neu",
text:
"Stillstehende Projekte melden sich, wenn länger kein Commit kam. Die Schwelle " +
"steht im Adminbereich.",
},
{
kind: "behoben",
text:
"Der neue Anlass fehlte im Prüfschema — die Oberfläche bot ihn an, der Server " +
"lehnte ihn ab. Ein Test hält beide Listen seitdem zusammen.",
},
],
},
{
version: "0.7.0",
date: "2026-07-26",
headline: "Bescheid bekommen, ohne nachzusehen.",
changes: [
{
kind: "neu",
text:
"Benachrichtigungen über ntfy, Webhook (HMAC-signiert) und E-Mail — bei " +
"fehlgeschlagenem Abgleich, roter CI oder neuer Version.",
},
{
kind: "besser",
text:
"Gemeldet wird nur bei einer Änderung. Sonst käme bei einem Repo mit abgelaufenem " +
"Token mit jedem Durchlauf dieselbe Nachricht.",
},
{
kind: "behoben",
text: "Im ntfy-Betreff wurden Emojis zu Fragezeichen, weil die Kopfzeile nur Latin-1 kann.",
},
],
},
{
version: "0.6.0",
date: "2026-07-26",
headline: "Prüfen statt behaupten.",
changes: [
{
kind: "neu",
text: "Ende-zu-Ende-Tests mit Playwright gegen die gebaute Anwendung und eine eigene Datenbank.",
},
{ kind: "neu", text: "CI-Pipeline für Gitea Actions." },
],
},
{
version: "0.5.0",
date: "2026-07-26",
headline: "Aufgaben je Projekt.",
changes: [
{
kind: "neu",
text: "Aufgabenbrett mit sortierbaren Spalten, Fälligkeitsdatum und Labels.",
},
{ kind: "neu", text: "Fortschritt wahlweise aus dem Verhältnis erledigter Aufgaben." },
],
},
{
version: "0.4.0",
date: "2026-07-26",
headline: "Ziehen und ablegen.",
changes: [
{ kind: "neu", text: "Kanban-Ansicht: Status per Ziehen ändern." },
{ kind: "neu", text: "CI-Status je Branch und Commit-Aktivität als Verlaufslinie." },
{
kind: "behoben",
text:
"Die Verlaufslinie log: bei 100 Commits innerhalb weniger Tage zeigte sie für die " +
"übrigen Tage Nullen, obwohl schlicht keine Daten vorlagen.",
},
],
},
{
version: "0.3.0",
date: "2026-07-26",
headline: "Text und Suche.",
changes: [
{ kind: "neu", text: "Markdown in Beschreibung und Notizen." },
{ kind: "neu", text: "Volltextsuche über Notizen, mit hervorgehobenen Fundstellen." },
{ kind: "neu", text: "Listen offener Pull Requests und Issues." },
{ kind: "behoben", text: "Die Versionsnummer stand an zwei Stellen und lief auseinander." },
],
},
{
version: "0.2.0",
date: "2026-07-25",
headline: "Konten und Härtung.",
changes: [
{ kind: "neu", text: "Benutzerverwaltung mit Passwort und Passkeys, Einzel- und Mehrbenutzerbetrieb." },
{ kind: "neu", text: "Export, Import und vollständige Sicherung." },
{ kind: "neu", text: "Automatischer Abgleich nach Zeitplan, einstellbar im Adminbereich." },
{
kind: "besser",
text:
"Härtung: CSRF-Prüfung, Content-Security-Policy mit Nonce, SSRF-Schutz mit " +
"Namensauflösung, Ratenbegrenzung und harte Datentrennung je Konto.",
},
{
kind: "behoben",
text:
"Menüs verschwanden hinter den Karten. Ursache war der Weichzeichner der " +
"Glasoptik, der einen eigenen Bezugsrahmen aufspannt; Menüs hängen seitdem am " +
"Seitenende statt in der Karte.",
},
{
kind: "behoben",
text:
"Eingabefelder überlappten ihre Symbole, weil eigene CSS-Klassen außerhalb der " +
"Kaskadenschichten gegen die Hilfsklassen gewannen.",
},
],
},
{
version: "0.1.0",
date: "2026-07-25",
headline: "Der Anfang.",
changes: [
{
kind: "neu",
text:
"Projekte mit Status, Priorität, Fortschritt, Tags und Notizen — von der reinen " +
"Idee bis zum fertigen Repo.",
},
{ kind: "neu", text: "Git-Anbindung an GitHub, GitLab und Gitea über Access Token." },
{ kind: "neu", text: "Betrieb per Docker oder eigenständig, Daten in einer lokalen PostgreSQL." },
],
},
];
+8
View File
@@ -79,6 +79,14 @@ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session";
/**
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
* vorne an.
*/
export const MFA_COOKIE = "nebula_mfa";
export const MFA_TTL_MS = 5 * 60 * 1000;
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
export function describeConfig(): string {
return [
+45 -2
View File
@@ -12,10 +12,17 @@ import { z } from "zod";
* ein Abgleich nach dem Import holt sie zurueck.
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
* wurde.
* - kein Verlauf. Er beschreibt, was in *dieser* Instanz passiert ist, und
* verwiese nach dem Import auf Konten, die es dort nicht gibt. Der Import
* setzt stattdessen einen eigenen ersten Eintrag.
*/
export const EXPORT_KIND = "nebula.projects";
export const EXPORT_VERSION = 1;
/**
* 2 seit den mehreren Repositories je Projekt. Dateien der Version 1 werden
* weiter gelesen: ihre einzelnen Repo-Felder werden zum ersten Repository.
*/
export const EXPORT_VERSION = 2;
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
@@ -43,6 +50,16 @@ export const exportedConnectionSchema = z.object({
baseUrl: z.string().max(300),
});
export const exportedRepositorySchema = z.object({
label: z.string().max(80).nullable().optional(),
repoUrl: z.string().max(500).nullable().optional(),
repoOwner: z.string().max(200).nullable().optional(),
repoName: z.string().max(200).nullable().optional(),
autoSync: z.boolean().optional().default(true),
/** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(),
});
export const exportedProjectSchema = z.object({
name: z.string().min(1).max(120),
slug: z.string().max(60).optional(),
@@ -54,11 +71,17 @@ export const exportedProjectSchema = z.object({
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),
/** Seit Version 2. */
repositories: z.array(exportedRepositorySchema).max(20).optional(),
/**
* Version 1 kannte genau ein Repository und schrieb es flach ans Projekt.
* Die Felder bleiben lesbar, damit aeltere Dateien weiter importierbar
* sind; geschrieben werden sie nicht mehr.
*/
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([]),
@@ -75,6 +98,26 @@ export const exportFileSchema = z.object({
});
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
export type ExportedRepository = z.infer<typeof exportedRepositorySchema>;
/**
* Die Repositories einer Exportdatei – gleich, ob sie aus Version 2 stammt
* oder aus der Zeit mit genau einem Repo je Projekt.
*/
export function repositoriesOf(project: ExportedProject): ExportedRepository[] {
if (project.repositories?.length) return project.repositories;
if (!project.repoUrl && !(project.repoOwner && project.repoName)) return [];
return [
{
label: null,
repoUrl: project.repoUrl ?? null,
repoOwner: project.repoOwner ?? null,
repoName: project.repoName ?? null,
autoSync: project.autoSync,
connection: project.connection ?? null,
},
];
}
export type ExportFile = z.infer<typeof exportFileSchema>;
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
+82 -40
View File
@@ -1,14 +1,27 @@
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
import type {
GitConnection,
Note,
Prisma,
Project,
RepoSnapshot,
Repository,
Task,
} from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { repoTitle } from "@/lib/repositories";
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 & {
export type RepositoryForExport = Repository & {
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
};
export type ProjectForExport = Project & {
notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
repositories: RepositoryForExport[];
};
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
@@ -24,17 +37,25 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
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,
// Version 1 hatte ein autoSync am Projekt. Das Feld ist im Schema noch
// vorhanden, damit alte Dateien lesbar bleiben; geschrieben wird der
// Wert des fuehrenden Repositories, damit eine Version-2-Datei auch von
// einer aelteren Instanz noch sinnvoll gelesen werden koennte.
autoSync: project.repositories[0]?.autoSync ?? true,
repositories: project.repositories.map((repository) => ({
label: repository.label,
repoUrl: repository.repoUrl,
repoOwner: repository.repoOwner,
repoName: repository.repoName,
autoSync: repository.autoSync,
connection: repository.connection
? {
name: repository.connection.name,
provider: repository.connection.provider,
baseUrl: repository.connection.baseUrl,
}
: null,
})),
notes: project.notes.map((note) => ({
title: note.title,
content: note.content,
@@ -72,15 +93,21 @@ export async function loadProjectsForExport(
): 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 } },
},
include: exportInclude,
orderBy: { name: "asc" },
});
}
/** Der Umfang, den der Export braucht – an beiden Aufrufstellen derselbe. */
export const exportInclude = {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
repositories: {
include: { connection: { select: { name: true, provider: true, baseUrl: true } } },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
} satisfies Prisma.ProjectInclude;
// ── Markdown ─────────────────────────────────────────────────
const PROVIDER_LABEL: Record<string, string> = {
@@ -102,7 +129,9 @@ const TASK_LABEL: Record<string, string> = {
* Datei auch ohne Netz vollstaendig ist.
*/
export function toMarkdown(
project: ProjectForExport & { snapshot: RepoSnapshot | null },
project: ProjectForExport & {
repositories: Array<RepositoryForExport & { snapshot: RepoSnapshot | null }>;
},
): string {
const status = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
@@ -119,33 +148,46 @@ export function toMarkdown(
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)}.`, "");
for (const repository of project.repositories) {
const titel = repoTitle(repository);
lines.push(
project.repositories.length === 1
? "## Repository"
: `## Repository: ${titel}`,
"",
);
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 ?? ""} |`,
);
if (repository.repoUrl) lines.push(`| Adresse | ${repository.repoUrl} |`);
if (repository.connection) {
const label =
PROVIDER_LABEL[repository.connection.provider] ?? repository.connection.provider;
lines.push(`| Git-System | ${label} (${repository.connection.baseUrl}) |`);
}
const snapshot = repository.snapshot;
if (snapshot) {
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(`| Abgerufen | ${formatDateTime(snapshot.fetchedAt)} |`);
} else {
lines.push("| Stand | noch nie abgeglichen |");
}
lines.push("");
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
CALENDAR_MAX_PAGES,
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
calendarTotal,
dayKey,
} from "./calendar";
/**
* Der Kalender darf ueber den Zeitraum nicht luegen. Reichen die geholten
* Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an – sie zeigt
* dann keine leeren Felder fuer Zeiten, ueber die schlicht nichts bekannt ist.
* Genau diesen Fehler hatte die Verlaufslinie schon einmal.
*/
const JETZT = new Date("2026-07-26T12:00:00.000Z");
const TAG = 86_400_000;
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) =>
new Date(JETZT.getTime() - (abTagen + index) * TAG).toISOString(),
);
}
describe("dayKey", () => {
it("liefert das Datum ohne Uhrzeit", () => {
expect(dayKey(new Date("2026-07-26T23:59:00.000Z"))).toBe("2026-07-26");
});
});
describe("calendarSince", () => {
it("liegt ein Jahr zurueck", () => {
const seit = calendarSince(JETZT);
expect(Math.round((JETZT.getTime() - seit.getTime()) / TAG)).toBe(365);
});
});
describe("calendarByPaging", () => {
it("liefert null, wenn es keine Commits gibt", async () => {
expect(await calendarByPaging(async () => [], JETZT)).toBeNull();
});
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
const heute = JETZT.toISOString();
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
expect(kalender?.days["2026-07-26"]).toBe(3);
expect(calendarTotal(kalender!)).toBe(3);
});
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
expect(kalender?.from).toBe("2026-07-17");
expect(kalender?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false);
});
it("hoert auf, sobald eine Seite nicht mehr voll ist", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1 ? commits(CALENDAR_PAGE_SIZE, 0) : commits(5, CALENDAR_PAGE_SIZE),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("deckelt die Zahl der Seiten und sagt es", async () => {
// Immer volle Seiten innerhalb des Zeitraums: ohne Deckel liefe das ewig.
const ladeSeite = vi.fn(async () =>
Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 3 * TAG).toISOString(),
),
);
const kalender = await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
expect(kalender?.truncated).toBe(true);
});
it("bricht ab, wenn eine ganze Seite aelter ist als der Zeitraum", async () => {
const ladeSeite = vi.fn(async (seite: number) =>
seite === 1
? commits(CALENDAR_PAGE_SIZE, 0)
: Array.from({ length: CALENDAR_PAGE_SIZE }, () =>
new Date(JETZT.getTime() - 400 * TAG).toISOString(),
),
);
await calendarByPaging(ladeSeite, JETZT);
expect(ladeSeite).toHaveBeenCalledTimes(2);
});
it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging(
async () => [
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(),
],
JETZT,
);
expect(kalender?.to).toBe("2026-07-26");
expect(calendarTotal(kalender!)).toBe(1);
});
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()],
JETZT,
);
expect(calendarTotal(kalender!)).toBe(1);
});
it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
const kalender = await calendarByPaging(
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
JETZT,
);
expect(Object.keys(kalender!.days)).toHaveLength(2);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Commits je Tag ueber ein Jahr – die Grundlage der Heatmap.
*
* Getrennt vom uebrigen Abgleich, weil es zusaetzliche Aufrufe kostet: der
* Kalender wird hoechstens einmal am Tag geholt. Von Stunde zu Stunde
* aendert sich an einem Jahresueberblick ohnehin nichts Sichtbares.
*
* Wichtig ist die Ehrlichkeit ueber den Zeitraum. Wo es keine
* Jahresstatistik gibt, reichen die Daten nur so weit zurueck wie die
* abgerufenen Seiten – dann faengt die Heatmap dort an und tut nicht so, als
* waere ein Jahr lang nichts passiert. Diesen Fehler hatte die Verlaufslinie
* schon einmal.
*/
/** Wie weit die Heatmap hoechstens zurueckreicht. */
export const CALENDAR_DAYS = 365;
/**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
* um kein fremdes Kontingent zu sprengen.
*/
export const CALENDAR_MAX_PAGES = 5;
export const CALENDAR_PAGE_SIZE = 100;
export interface CommitCalendar {
/** Erster Tag, fuer den wirklich Daten vorliegen (yyyy-mm-dd). */
from: string;
/** Letzter beruecksichtigter Tag (yyyy-mm-dd). */
to: string;
/** Nur Tage mit Commits: { "2026-07-26": 3 }. Die Nullen ergaenzt die Anzeige. */
days: Record<string, number>;
/**
* "stats" = Jahresstatistik des Anbieters, der Zeitraum ist vollstaendig.
* "commits" = so weit, wie die Seiten zurueckreichten.
*/
source: "stats" | "commits";
/** Der Deckel hat gegriffen; aeltere Commits fehlen. */
truncated: boolean;
}
/** Tag im Format yyyy-mm-dd, in UTC – so liefern es die Anbieter. */
export function dayKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/** Der Tag, ab dem der Kalender hoechstens reicht. */
export function calendarSince(now: Date = new Date()): Date {
return new Date(now.getTime() - CALENDAR_DAYS * 86_400_000);
}
/**
* Holt seitenweise Commit-Zeitpunkte und zaehlt sie zu Tagen zusammen.
*
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* Zeitraum stammt, oder wenn der Deckel greift.
*/
export async function calendarByPaging(
ladeSeite: (seite: number) => Promise<Array<string | null>>,
now: Date = new Date(),
): Promise<CommitCalendar | null> {
const grenze = calendarSince(now).getTime();
const days: Record<string, number> = {};
let aeltester: number | null = null;
let gedeckelt = false;
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
const daten = await ladeSeite(seite);
if (daten.length === 0) break;
let innerhalb = 0;
for (const value of daten) {
if (!value) continue;
const time = Date.parse(value);
if (Number.isNaN(time)) continue;
// Ein Commit-Datum in der Zukunft kommt vor – falsch gestellte Uhren.
if (time > now.getTime()) continue;
if (time < grenze) continue;
innerhalb += 1;
const key = dayKey(new Date(time));
days[key] = (days[key] ?? 0) + 1;
if (aeltester === null || time < aeltester) aeltester = time;
}
// Nicht mehr voll: das war die letzte Seite.
if (daten.length < CALENDAR_PAGE_SIZE) break;
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
if (innerhalb === 0) break;
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
}
if (aeltester === null) return null;
return {
from: dayKey(new Date(aeltester)),
to: dayKey(now),
days,
source: "commits",
truncated: gedeckelt,
};
}
/** Wie viele Commits der Kalender insgesamt kennt. */
export function calendarTotal(calendar: CommitCalendar): number {
return Object.values(calendar.days).reduce((sum, value) => sum + value, 0);
}
+65
View File
@@ -1,4 +1,11 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type {
BranchInfo,
CiInfo,
@@ -219,6 +226,64 @@ export class GiteaClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
};
}
/**
* Gitea kennt keine Jahresstatistik ueber die API, also seitenweise.
* `stat=false` und `verification=false` sparen dabei spuerbar Rechenzeit
* auf der Gegenseite – gebraucht wird nur das Datum.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const seit = calendarSince().toISOString();
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
// Daten" durchgehen – siehe maybeRefreshCalendar.
return calendarByPaging(async (seite) => {
const commits = await getJson<GiteaCommit[]>(
`${base}/commits?limit=${CALENDAR_PAGE_SIZE}&page=${seite}` +
`&stat=false&verification=false&files=false&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() },
);
return commits.map((commit) => toIso(commit.commit?.author?.date));
});
}
/**
* Gitea kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/contents`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{
content?: string;
encoding?: string;
path?: string;
html_url?: string | null;
}>(`${base}/contents/${encodeURIComponent(name)}`, { headers });
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? name,
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
}
function mapCommitStatus(state: string): CiState {
+92
View File
@@ -3,6 +3,7 @@ import type {
BranchInfo,
CiInfo,
CiState,
CommitCalendar,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -14,6 +15,8 @@ import type {
TagInfo,
TicketInfo,
} from "./types";
import { dayKey } from "./calendar";
import { capReadme, decodeBase64, type RepoReadme } from "./readme";
import { GitApiError, bucketCommitActivity } from "./types";
/**
@@ -206,6 +209,95 @@ export class GitHubClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
};
}
/**
* GitHub fuehrt die Statistik selbst: /stats/commit_activity liefert 52
* Wochen mit Tageswerten in einem einzigen Aufruf. Das ist deutlich
* sparsamer, als ein Jahr Commits seitenweise abzuholen.
*
* Die Statistik wird bei Bedarf erst berechnet; dann antwortet GitHub mit
* 202 und einem leeren Rumpf – daher der leere Fall unten.
*
* Bewusst getJson und nicht getJsonSafe: ein Rate-Limit oder ein Ausfall
* darf nicht als "keine Daten" durchgehen. Der Fehler geht nach oben, und
* der naechste Abgleich versucht es erneut.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
headers: this.headers(),
});
if (!Array.isArray(wochen) || wochen.length === 0) {
// Entweder rechnet GitHub noch, oder das Repository hat keine Commits.
// Ersatzweise aus den geholten Commits zu bauen waere irrefuehrend –
// das taete so, als reichte der Zeitraum ein Jahr zurueck.
return null;
}
const days: Record<string, number> = {};
let von: string | null = null;
const heute = dayKey(new Date());
for (const woche of wochen) {
if (!woche?.week || !Array.isArray(woche.days)) continue;
woche.days.forEach((anzahl, index) => {
const tag = dayKey(new Date((woche.week + index * 86_400) * 1000));
if (tag > heute) return;
if (von === null || tag < von) von = tag;
if (anzahl > 0) days[tag] = anzahl;
});
}
if (von === null) return null;
return { from: von, to: heute, days, source: "stats", truncated: false };
}
/**
* GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet – ein
* Aufruf genuegt. Ohne README antwortet er mit 404; das ist kein Fehler,
* sondern die Auskunft "gibt es nicht".
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
let datei: GitHubContent;
try {
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
} catch (error) {
if (error instanceof GitApiError && error.status === 404) return null;
throw error;
}
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? datei.name ?? "README",
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
}
interface GitHubContent {
name?: string;
path?: string;
content?: string;
encoding?: string;
html_url?: string | null;
}
interface GitHubWeek {
/** Unix-Zeit des Wochenanfangs (Sonntag). */
week: number;
/** Sieben Werte, Sonntag bis Samstag. */
days: number[];
total: number;
}
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
+71 -3
View File
@@ -1,4 +1,11 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type {
BranchInfo,
CiInfo,
@@ -76,10 +83,13 @@ export class GitLabClient implements GitClient {
});
}
/** "gruppe/sub/projekt" URL-kodiert – so adressiert GitLab ein Projekt. */
private projectPath(ref: RepoRef): string {
return encodeURIComponent([ref.owner, ref.name].filter(Boolean).join("/"));
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const path = [ref.owner, ref.name].filter(Boolean).join("/");
const encoded = encodeURIComponent(path);
const base = `${this.apiBase}/projects/${encoded}`;
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
@@ -198,6 +208,64 @@ export class GitLabClient implements GitClient {
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
};
}
/**
* GitLab kennt keine fertige Jahresstatistik, also seitenweise mit `since`.
* Der Deckel in calendarByPaging begrenzt das auf wenige Aufrufe.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const seit = calendarSince().toISOString();
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
// Daten" durchgehen – siehe maybeRefreshCalendar.
return calendarByPaging(async (seite) => {
const commits = await getJson<GitLabCommit[]>(
`${base}/repository/commits?per_page=${CALENDAR_PAGE_SIZE}&page=${seite}` +
`&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() },
);
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
});
}
/**
* GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/repository/tree?per_page=100`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
{ headers },
);
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
return {
content,
path: datei.file_path ?? name,
baseUrl:
projekt?.web_url && projekt.default_branch
? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/`
: null,
truncated,
};
}
}
function mapPipelineState(status: string | null): CiState {
+19
View File
@@ -29,6 +29,25 @@ export const PROVIDER_META: Record<
},
};
/**
* Wie ein Repository in Ueberschriften und Meldungen heisst.
*
* Steht hier und nicht bei den uebrigen Repository-Helfern, weil die
* Oberflaeche sie braucht: lib/repositories zieht Prisma und damit
* node:-Module nach, die in einer Client-Komponente nicht landen duerfen.
*/
export function repoTitle(repo: {
label?: string | null;
repoOwner?: string | null;
repoName?: string | null;
repoUrl?: string | null;
}): string {
if (repo.label) return repo.label;
if (repo.repoOwner && repo.repoName) return `${repo.repoOwner}/${repo.repoName}`;
if (repo.repoName) return repo.repoName;
return repo.repoUrl ?? "Repository";
}
export function guessProvider(host: string): ProviderKind {
const lower = host.toLowerCase();
if (lower.includes("github")) return "GITHUB";
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme";
describe("pickReadme", () => {
it("findet die uebliche Schreibweise", () => {
expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md");
});
it("ist bei der Gross- und Kleinschreibung nachsichtig", () => {
expect(pickReadme(["readme.md"])).toBe("readme.md");
expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD");
});
it("zieht Markdown den uebrigen Formaten vor", () => {
// Beides kommt vor; nur das erste laesst sich wirklich darstellen.
expect(pickReadme(["README.rst", "README.md"])).toBe("README.md");
});
it("nimmt auch ein README ohne Endung", () => {
expect(pickReadme(["LICENSE", "README"])).toBe("README");
});
it("liefert null, wenn keines dabei ist", () => {
expect(pickReadme(["src", "package.json"])).toBeNull();
expect(pickReadme([])).toBeNull();
});
it("verwechselt Aehnliches nicht mit dem README", () => {
expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull();
});
});
describe("capReadme", () => {
it("laesst Kurzes unangetastet", () => {
const { content, truncated } = capReadme("# Titel\n\nEin Satz.");
expect(content).toBe("# Titel\n\nEin Satz.");
expect(truncated).toBe(false);
});
it("kuerzt Langes an einer Zeilengrenze", () => {
// Sonst bliebe womoeglich ein halber Codeblock stehen.
const zeile = "x".repeat(80);
const lang = Array.from({ length: 2000 }, () => zeile).join("\n");
const { content, truncated } = capReadme(lang);
expect(truncated).toBe(true);
expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS);
expect(content.endsWith("x")).toBe(true);
expect(content.split("\n").at(-1)).toBe(zeile);
});
it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => {
// Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1.
const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500));
expect(truncated).toBe(true);
expect(content).toHaveLength(README_MAX_CHARS);
});
});
describe("decodeBase64", () => {
it("dekodiert UTF-8 richtig", () => {
// Ueber Bytes gedacht kaeme hier Kauderwelsch heraus.
const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64");
expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht");
});
it("uebergeht Zeilenumbrueche in der Kodierung", () => {
// GitHub bricht den base64-Block alle 60 Zeichen um.
const roh = Buffer.from("Hallo Welt", "utf8").toString("base64");
const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`;
expect(decodeBase64(umgebrochen)).toBe("Hallo Welt");
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter.
*
* Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln
* muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
*/
export interface RepoReadme {
/** Roher Markdown-Text. */
content: string;
/** Dateiname im Repository, etwa "README.md". */
path: string;
/**
* Basis, gegen die relative Links im Text aufzuloesen sind – die
* Web-Ansicht des Repositories im Verzeichnis des READMEs.
*/
baseUrl: string | null;
/** Wurde am Deckel abgeschnitten? */
truncated: boolean;
}
/**
* Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in
* einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer
* Zeilengrenze, damit kein halber Codeblock stehen bleibt.
*/
export const README_MAX_CHARS = 60_000;
/**
* Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst,
* weil nur das dargestellt werden kann; die uebrigen landen als Klartext in
* einem Codeblock, was immer noch besser ist als nichts.
*/
const KANDIDATEN = [
"readme.md",
"readme.markdown",
"readme.mdown",
"readme",
"readme.txt",
"readme.rst",
];
/**
* Sucht aus einer Verzeichnisauflistung die README-Datei heraus.
* Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet.
*/
export function pickReadme(namen: string[]): string | null {
const klein = new Map(namen.map((name) => [name.toLowerCase(), name]));
for (const kandidat of KANDIDATEN) {
const treffer = klein.get(kandidat);
if (treffer) return treffer;
}
return null;
}
/** Kuerzt am Deckel, aber an einer Zeilengrenze. */
export function capReadme(text: string): { content: string; truncated: boolean } {
if (text.length <= README_MAX_CHARS) return { content: text, truncated: false };
const roh = text.slice(0, README_MAX_CHARS);
const letzterUmbruch = roh.lastIndexOf("\n");
return {
// Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast
// nichts uebrig.
content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh,
truncated: true,
};
}
/**
* Dekodiert, was die Anbieter als base64 liefern.
*
* Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob
* gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen.
*/
export function decodeBase64(value: string): string {
return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8");
}
+17
View File
@@ -1,4 +1,8 @@
import type { GitProvider } from "@prisma/client";
import type { CommitCalendar } from "./calendar";
import type { RepoReadme } from "./readme";
export type { CommitCalendar, RepoReadme };
export type ProviderKind = GitProvider;
@@ -171,6 +175,19 @@ export interface GitClient {
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>;
/**
* Commits je Tag ueber ein Jahr – fuer die Heatmap.
*
* Getrennt von fetchRepo, weil es zusaetzliche Aufrufe kostet und nur
* hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen.
*/
fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null>;
/**
* Das README des Repositories. Wie der Kalender getrennt, weil es
* zusaetzliche Aufrufe kostet und sich selten aendert. null heisst: es
* gibt keins.
*/
fetchReadme(ref: RepoRef): Promise<RepoReadme | null>;
}
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
+36 -22
View File
@@ -1,6 +1,7 @@
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client";
import type { NotificationEvent, Project, RepoSnapshot, Repository } from "@prisma/client";
import { APP_NAME, APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { repoTitle } from "@/lib/repositories";
import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types";
@@ -14,25 +15,26 @@ export { emailAvailable, smtpConfig } from "./channels";
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
* Der zuletzt gemeldete Zustand steht am Projekt.
* Der zuletzt gemeldete Zustand steht am Repository – nicht am Projekt, sonst
* verdeckte ein rotes Frontend die rote Pipeline des Backends.
*
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
* darf den Abgleich nicht scheitern lassen.
*/
export async function notifyAfterSync(projectId: string): Promise<void> {
export async function notifyAfterSync(repositoryId: string): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { snapshot: true },
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: { snapshot: true, project: true },
});
if (!project?.ownerId) return;
if (!repository?.project.ownerId) return;
const faellig = pendingEvents(project, project.snapshot);
const faellig = pendingEvents(repository.project, repository, repository.snapshot);
if (faellig.length === 0) return;
const ziele = await prisma.notificationTarget.findMany({
where: {
userId: project.ownerId,
userId: repository.project.ownerId,
isActive: true,
events: { hasSome: faellig.map((entry) => entry.event) },
},
@@ -40,12 +42,12 @@ export async function notifyAfterSync(projectId: string): Promise<void> {
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
await prisma.project.update({
where: { id: project.id },
await prisma.repository.update({
where: { id: repository.id },
data: {
notifiedSyncError: project.syncError,
notifiedCiState: project.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(project.snapshot),
notifiedSyncError: repository.syncError,
notifiedCiState: repository.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(repository.snapshot),
},
});
@@ -81,6 +83,7 @@ function currentRelease(snapshot: RepoSnapshot | null): string | null {
*/
export function pendingEvents(
project: Project,
repository: Repository,
snapshot: RepoSnapshot | null,
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
@@ -88,13 +91,24 @@ export function pendingEvents(
const bezug = { id: project.id, name: project.name, slug: project.slug };
const jetzt = new Date().toISOString();
if (project.syncError && project.syncError !== project.notifiedSyncError) {
/**
* Der Betreff nennt das Repository nur, wenn es nicht ohnehin so heisst
* wie das Projekt. "nebula – nebula: CI ist rot" liest niemand gern.
*/
const titel = repoTitle(repository);
const woher =
titel.toLowerCase() === project.name.toLowerCase() ||
titel.toLowerCase().endsWith(`/${project.name.toLowerCase()}`)
? project.name
: `${project.name} · ${titel}`;
if (repository.syncError && repository.syncError !== repository.notifiedSyncError) {
treffer.push({
event: "SYNC_FAILED",
message: {
event: "SYNC_FAILED",
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
body: project.syncError,
title: `${EVENT_META.SYNC_FAILED.emoji} ${woher}: Abgleich fehlgeschlagen`,
body: repository.syncError,
priority: "high",
url: projektUrl,
project: bezug,
@@ -104,12 +118,12 @@ export function pendingEvents(
}
const ci = snapshot?.ciState ?? null;
if (ci === "failure" && ci !== project.notifiedCiState) {
if (ci === "failure" && ci !== repository.notifiedCiState) {
treffer.push({
event: "CI_FAILED",
message: {
event: "CI_FAILED",
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`,
title: `${EVENT_META.CI_FAILED.emoji} ${woher}: CI ist rot`,
body:
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
@@ -125,13 +139,13 @@ export function pendingEvents(
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
// vorher schon einmal etwas festgehalten wurde.
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
if (release && repository.notifiedRelease && release !== repository.notifiedRelease) {
treffer.push({
event: "NEW_RELEASE",
message: {
event: "NEW_RELEASE",
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
title: `${EVENT_META.NEW_RELEASE.emoji} ${woher}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${repository.notifiedRelease}).`,
priority: "normal",
url: snapshot?.htmlUrl ?? projektUrl,
project: bezug,
+57 -22
View File
@@ -1,24 +1,36 @@
import { describe, expect, it } from "vitest";
import type { Project, RepoSnapshot } from "@prisma/client";
import type { Project, RepoSnapshot, Repository } from "@prisma/client";
import { pendingEvents } from "./index";
/**
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
*
* Der Merkstand liegt am Repository, nicht am Projekt – sonst verdeckte ein
* rotes Frontend die rote Pipeline des Backends.
*/
function projekt(overrides: Partial<Project> = {}): Project {
const PROJEKT: Project = {
id: "p1",
name: "Testprojekt",
slug: "testprojekt",
} as Project;
function repo(overrides: Partial<Repository> = {}): Repository {
return {
id: "p1",
name: "Testprojekt",
slug: "testprojekt",
id: "r1",
projectId: "p1",
label: null,
repoOwner: "wer",
repoName: "testprojekt",
repoUrl: null,
syncError: null,
notifiedSyncError: null,
notifiedCiState: null,
notifiedRelease: null,
...overrides,
} as Project;
} as Repository;
}
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
@@ -34,49 +46,49 @@ function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
} as RepoSnapshot;
}
const events = (p: Project, s: RepoSnapshot | null) =>
pendingEvents(p, s).map((entry) => entry.event);
const events = (r: Repository, s: RepoSnapshot | null) =>
pendingEvents(PROJEKT, r, s).map((entry) => entry.event);
describe("Abgleichsfehler", () => {
it("meldet einen neuen Fehler", () => {
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
expect(events(repo({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
});
it("meldet denselben Fehler kein zweites Mal", () => {
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
expect(events(repo({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
});
it("meldet, wenn sich der Fehler aendert", () => {
expect(
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
events(repo({ syncError: "401 Token", notifiedSyncError: "404" }), null),
).toEqual(["SYNC_FAILED"]);
});
it("meldet nichts, wenn kein Fehler vorliegt", () => {
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
expect(events(repo({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
});
});
describe("CI", () => {
it("meldet den Wechsel auf rot", () => {
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
expect(events(repo(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
});
it("meldet dauerhaft rote CI nur einmal", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
).toEqual([]);
});
it("meldet nicht, wenn die CI gruen ist", () => {
expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
).toEqual([]);
});
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
expect(
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
events(repo({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
).toEqual(["CI_FAILED"]);
});
});
@@ -85,40 +97,63 @@ describe("Release", () => {
it("schweigt beim allerersten Abgleich", () => {
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
expect(
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
events(repo({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
).toEqual([]);
});
it("meldet eine neue Version", () => {
expect(
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
events(repo({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual(["NEW_RELEASE"]);
});
it("meldet dieselbe Version nicht erneut", () => {
expect(
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
events(repo({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual([]);
});
it("zieht den Releasenamen dem Tag vor", () => {
const treffer = pendingEvents(
projekt({ notifiedRelease: "alt" }),
PROJEKT,
repo({ notifiedRelease: "alt" }),
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
);
expect(treffer[0].message.title).toContain("Zweite Ausgabe");
});
});
describe("Betreff bei mehreren Repositories", () => {
it("nennt das Repository nicht, wenn es ohnehin so heisst wie das Projekt", () => {
// "Testprojekt · wer/testprojekt: CI ist rot" liest niemand gern.
const treffer = pendingEvents(
PROJEKT,
repo({ repoOwner: "wer", repoName: "testprojekt" }),
snapshot({ ciState: "failure" }),
);
expect(treffer[0].message.title).toContain("Testprojekt: CI ist rot");
expect(treffer[0].message.title).not.toContain("wer/");
});
it("nennt es, sobald es sich unterscheidet", () => {
const treffer = pendingEvents(
PROJEKT,
repo({ label: "Backend" }),
snapshot({ ciState: "failure" }),
);
expect(treffer[0].message.title).toContain("Testprojekt · Backend");
});
});
describe("mehrere Anlaesse gleichzeitig", () => {
it("meldet Fehler und rote CI zusammen", () => {
expect(
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
events(repo({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
});
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
const treffer = pendingEvents(projekt({ syncError: "404" }), null);
const treffer = pendingEvents(PROJEKT, repo({ syncError: "404" }), null);
expect(treffer[0].message.project?.slug).toBe("testprojekt");
expect(treffer[0].message.url).toContain("/projects/p1");
expect(treffer[0].message.priority).toBe("high");
+24
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { newestCommit } from "./stale";
import { daysSince, isStaleDue } from "./stale";
/**
@@ -59,3 +60,26 @@ describe("daysSince", () => {
expect(daysSince(jetzt, jetzt)).toBe(0);
});
});
describe("newestCommit", () => {
it("nimmt den juengsten Commit ueber alle Repositories", () => {
// An einem Vorhaben wird gearbeitet, sobald an einem seiner Teile
// gearbeitet wird – ein ruhendes Backend macht es nicht stillstehend.
const juengster = newestCommit([
{ lastCommitAt: vorTagen(90) },
{ lastCommitAt: vorTagen(3) },
{ lastCommitAt: vorTagen(40) },
]);
expect(daysSince(juengster!, jetzt)).toBe(3);
});
it("uebergeht Repositories ohne Commit-Datum", () => {
const juengster = newestCommit([{ lastCommitAt: null }, { lastCommitAt: vorTagen(5) }]);
expect(daysSince(juengster!, jetzt)).toBe(5);
});
it("liefert null, wenn nirgends ein Commit bekannt ist", () => {
expect(newestCommit([])).toBeNull();
expect(newestCommit([{ lastCommitAt: null }])).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More