Files
nebula/prisma/schema.prisma
T
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

528 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─────────────────────────────────────────────────────────────
// Nebula – Datenmodell
// Datenbank: PostgreSQL (lokal oder via docker compose)
// ─────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
// linux-musl-openssl-3.0.x wird fuer das Alpine-Docker-Image benoetigt.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Lebenszyklus eines Projekts. IDEA ist der Startzustand fuer
/// frisch notierte Einfaelle, ARCHIVED blendet Projekte aus dem
/// Standard-Board aus, ohne sie zu loeschen.
enum ProjectStatus {
IDEA
OPEN
PLANNING
IN_DEVELOPMENT
DONE
ARCHIVED
}
/// Unterstuetzte Git-Systeme.
enum GitProvider {
GITHUB
GITLAB
GITEA
}
/// Zustellwege fuer Benachrichtigungen.
enum NotificationChannel {
WEBHOOK
NTFY
EMAIL
}
/// Anlaesse, ueber die benachrichtigt werden kann.
enum NotificationEvent {
/// Ein Abgleich mit dem Git-System ist fehlgeschlagen.
SYNC_FAILED
/// Der letzte CI-Lauf auf dem Default-Branch ist rot.
CI_FAILED
/// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht.
NEW_RELEASE
/// Seit laengerem kein Commit mehr – der sanfte Stups.
PROJECT_STALE
}
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
/// mitschreiben.
enum ActivityKind {
PROJECT_CREATED
PROJECT_UPDATED
STATUS_CHANGED
NOTE_ADDED
NOTE_UPDATED
NOTE_DELETED
TASK_ADDED
TASK_MOVED
TASK_DELETED
TASKS_IMPORTED
REPO_LINKED
REPO_COMMITS
SYNC_FAILED
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole {
ADMIN
USER
}
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
enum AppMode {
SINGLE_USER
MULTI_USER
}
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
/// ausschliesslich per Passkey nutzbar sein darf.
model User {
id String @id @default(cuid())
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
/// gross-/kleinschreibungsunabhaengig funktioniert.
username String @unique
displayName String?
email String? @unique
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
passwordHash String?
role UserRole @default(USER)
isActive Boolean @default(true)
lastLoginAt DateTime?
/// Zaehler und Sperre gegen Passwortraten.
failedLoginCount Int @default(0)
lockedUntil DateTime?
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
sessions Session[]
credentials Credential[]
projects Project[]
connections GitConnection[]
notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
lastSeenAt DateTime @default(now())
userAgent String?
ip String?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Ein registrierter Passkey (WebAuthn-Credential).
model Credential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// base64url-kodierte Credential-ID des Authenticators.
credentialId String @unique
publicKey Bytes
counter Int @default(0)
transports String[] @default([])
deviceType String?
backedUp Boolean @default(false)
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
name String?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
/// Jeder Code gilt genau einmal.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
model PendingLogin {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
tokenHash String @unique
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge {
id String @id @default(cuid())
challenge String @unique
/// null bei der Anmeldung ueber auffindbare Passkeys.
userId String?
/// "registration" oder "authentication"
purpose String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([expiresAt])
}
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
model AppSettings {
id String @id @default("singleton")
mode AppMode @default(SINGLE_USER)
/// Nur im Mehrbenutzerbetrieb wirksam.
allowRegistration Boolean @default(false)
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
rpName String @default("Nebula")
// ── Automatischer Abgleich mit den Git-Systemen ────────────
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
/// das soll eine bewusste Entscheidung sein.
autoSyncEnabled Boolean @default(false)
/// Abstand zwischen zwei Durchlaeufen in Minuten.
autoSyncIntervalMinutes Int @default(60)
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
/// noch etwas.
autoSyncActiveOnly Boolean @default(true)
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
/// einen Schlag in ein Rate-Limit laeuft.
autoSyncBatchSize Int @default(25)
lastAutoSyncAt DateTime?
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json?
// ── Stillstand ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60)
/// Die Pruefung laeuft hoechstens einmal am Tag.
lastStaleCheckAt DateTime?
updatedAt DateTime @updatedAt
}
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
model GitConnection {
id String @id @default(cuid())
name String
provider GitProvider
/// Basis-URL der Instanz, z. B. https://github.com, https://gitlab.com,
/// https://git.meine-domain.de
baseUrl String
/// Optionaler Benutzername – rein informativ fuer die Anzeige.
username String?
/// Personal Access Token, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
tokenCipher String?
/// Gekuerzte Darstellung des Tokens fuer die UI, z. B. "ghp_…4f2a".
tokenHint String?
lastCheckedAt DateTime?
lastCheckOk Boolean?
lastCheckMessage String?
/// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der
/// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten
/// Kontos werden sie diesem zugeordnet.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([provider])
@@index([ownerId])
}
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
model Project {
id String @id @default(cuid())
name String
/// Pro Besitzer eindeutig – siehe @@unique unten.
slug String
/// Einzeiler fuer die Karte im Dashboard.
summary String?
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
description String?
status ProjectStatus @default(IDEA)
/// 1 = niedrig, 2 = normal, 3 = hoch, 4 = kritisch
priority Int @default(2)
/// Fortschritt in Prozent (0–100)
progress Int @default(0)
/// Akzentfarbe der Karte: violet | cyan | emerald | amber | rose | blue
accent String @default("violet")
tags String[] @default([])
favorite Boolean @default(false)
// ── 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)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
activities Activity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([connectionId])
@@index([updatedAt])
@@index([ownerId])
}
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
model NotificationTarget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
channel NotificationChannel
/// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse.
target String
/// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer
/// ntfy oder ein Signaturschluessel fuer den Webhook.
secretCipher String?
secretHint String?
/// Worueber dieses Ziel benachrichtigt werden will.
events NotificationEvent[] @default([])
isActive Boolean @default(true)
lastSentAt DateTime?
lastOk Boolean?
lastMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
model Task {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
description String?
status TaskStatus @default(TODO)
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
position Int @default(0)
dueDate DateTime?
labels String[] @default([])
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer und Link merken, damit
/// dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int?
sourceUrl 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])
@@index([projectId, status, position])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
/// automatischen Abgleich.
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
kind ActivityKind
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
summary String
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
meta Json?
createdAt DateTime @default(now())
/// Absteigend gelesen, immer je Projekt.
@@index([projectId, createdAt])
}
/// Freie Notizen zu einem Projekt.
model Note {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String?
content String
pinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId])
@@index([pinned])
}
/// Zwischengespeicherter Stand des verknuepften 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)
fullName String?
description String?
defaultBranch String?
htmlUrl String?
visibility String?
stars Int @default(0)
forks Int @default(0)
openIssues Int @default(0)
openPullRequests Int @default(0)
sizeKb Int @default(0)
lastCommitSha String?
lastCommitMessage String?
lastCommitAuthor String?
lastCommitAt DateTime?
latestTag String?
latestRelease String?
latestReleaseAt DateTime?
branchCount Int @default(0)
tagCount Int @default(0)
/// Rohdaten der Listen – bewusst als JSON, damit providerspezifische
/// Zusatzfelder nicht verloren gehen.
branches Json @default("[]")
tags Json @default("[]")
commits Json @default("[]")
releases Json @default("[]")
languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
pushedAt DateTime?
fetchedAt DateTime @default(now())
}