// ───────────────────────────────────────────────────────────── // 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$$; 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) repositories Repository[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([ownerId, name]) @@index([provider]) @@index([ownerId]) } /// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo. model Project { id String @id @default(cuid()) name String /// Pro Besitzer eindeutig – siehe @@unique unten. slug String /// Einzeiler fuer die Karte im Dashboard. summary String? /// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich). description String? status ProjectStatus @default(IDEA) /// 1 = niedrig, 2 = normal, 3 = hoch, 4 = kritisch priority Int @default(2) /// Fortschritt in Prozent (0–100) progress Int @default(0) /// Akzentfarbe der Karte: violet | cyan | emerald | amber | rose | blue accent String @default("violet") tags String[] @default([]) favorite Boolean @default(false) /// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die /// uebrigen Ansichten folgen der gewaehlten Sortierung. position Int @default(0) /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Einrichten des ersten Kontos uebernommen werden koennen. ownerId String? owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) /// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt /// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt. progressFromTasks Boolean @default(false) /// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird /// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde. /// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht /// ueber ein einzelnes Repository. notifiedStaleAt DateTime? notes Note[] tasks Task[] activities Activity[] repositories Repository[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([ownerId, slug]) @@index([status]) @@index([updatedAt]) @@index([ownerId]) @@index([ownerId, status, position]) } /// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren – /// Frontend und Backend etwa –, gehoert aber in eine Karte. /// /// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen /// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist. model Repository { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) /// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name /// steht fuer sich. label String? repoUrl String? /// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe") repoOwner String? repoName String? defaultBranch String? connectionId String? connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) /// Reihenfolge in der Anzeige. position Int @default(0) lastSyncedAt DateTime? syncError String? /// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei /// einem Repo, dessen Token staendig Fehler wirft. autoSync Boolean @default(true) /// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach /// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert. syncFailureCount Int @default(0) /// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer /// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem /// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst /// verdeckt ein rotes Frontend die rote Pipeline des Backends. notifiedSyncError String? notifiedCiState String? notifiedRelease String? snapshot RepoSnapshot? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([projectId, position]) @@index([connectionId]) } /// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto. model NotificationTarget { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) name String channel NotificationChannel /// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse. target String /// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer /// ntfy oder ein Signaturschluessel fuer den Webhook. secretCipher String? secretHint String? /// Worueber dieses Ziel benachrichtigt werden will. events NotificationEvent[] @default([]) isActive Boolean @default(true) lastSentAt DateTime? lastOk Boolean? lastMessage String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([userId, name]) @@index([userId]) } /// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts. model Task { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) title String /// Optionale Notiz zur Aufgabe, Markdown wird dargestellt. description String? status TaskStatus @default(TODO) /// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn /// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt. /// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert. position Int @default(0) dueDate DateTime? labels String[] @default([]) /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. doneAt DateTime? /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link /// merken, damit dasselbe Issue nicht zweimal uebernommen wird. sourceIssue Int? sourceUrl String? /// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und /// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben. sourceRepositoryId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt /// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne /// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden. @@unique([projectId, sourceRepositoryId, sourceIssue]) @@index([projectId, status, position]) } /// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert. model Activity { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) /// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim /// automatischen Abgleich. userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) kind ActivityKind /// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben /// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es /// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz. summary String /// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht /// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll. meta Json? createdAt DateTime @default(now()) /// Absteigend gelesen, immer je Projekt. @@index([projectId, createdAt]) } /// Freie Notizen zu einem Projekt. model Note { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) title String? content String pinned Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([projectId]) @@index([pinned]) } /// Zwischengespeicherter Stand eines Repositories. Wird beim Sync /// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt. model RepoSnapshot { id String @id @default(cuid()) repositoryId String @unique repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) fullName String? description String? defaultBranch String? htmlUrl String? visibility String? stars Int @default(0) forks Int @default(0) openIssues Int @default(0) openPullRequests Int @default(0) sizeKb Int @default(0) lastCommitSha String? lastCommitMessage String? lastCommitAuthor String? lastCommitAt DateTime? latestTag String? latestRelease String? latestReleaseAt DateTime? branchCount Int @default(0) tagCount Int @default(0) /// Rohdaten der Listen – bewusst als JSON, damit providerspezifische /// Zusatzfelder nicht verloren gehen. branches Json @default("[]") tags Json @default("[]") commits Json @default("[]") releases Json @default("[]") languages Json @default("{}") pullRequests Json @default("[]") issues Json @default("[]") /// Commits je Tag der letzten 30 Tage, aeltester zuerst. commitActivity Json @default("[]") // ── 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()) }