// Datenmodell des Planers. // Fachbegriffe auf Deutsch, technische Felder auf Englisch. generator client { provider = "prisma-client" output = "../src/generated/prisma" moduleFormat = "esm" } datasource db { provider = "postgresql" } enum Rolle { ADMIN BENUTZER } /// Ein Mensch, der sich anmelden darf. model Benutzer { id String @id @default(cuid()) email String @unique name String? /// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist. passwortHash String? rolle Rolle @default(BENUTZER) aktiv Boolean @default(true) bildUrl String? emailGeprueft DateTime? zuletztAktiv DateTime? erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt konten Konto[] passkeys Passkey[] audit AuditEintrag[] kalenderkonten Kalenderkonto[] benachrichtigung Benachrichtigungseinstellung? pushGeraete PushGeraet[] meldungen Meldungsnachweis[] @@index([aktiv]) @@map("benutzer") } /// Verknuepfung zu einem OIDC-Anbieter (Entra ID, authentik). model Konto { id String @id @default(cuid()) benutzerId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@index([benutzerId]) @@map("konto") } /// Ein registrierter Passkey (WebAuthn) eines Benutzers. model Passkey { id String @id @default(cuid()) benutzerId String /// Base64URL der Credential-ID. credentialId String @unique publicKey Bytes counter BigInt @default(0) /// Kommagetrennt, z. B. "internal,hybrid". transports String? geraetetyp String? gesichert Boolean @default(false) bezeichnung String erstelltAm DateTime @default(now()) zuletztBenutzt DateTime? benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@index([benutzerId]) @@map("passkey") } /// Laufende WebAuthn-Herausforderung; kurzlebig, wird nach Gebrauch geloescht. model WebauthnChallenge { id String @id @default(cuid()) /// E-Mail bei der Anmeldung, Benutzer-ID bei der Registrierung. kennung String challenge String zweck String gueltigBis DateTime @@index([kennung, zweck]) @@map("webauthn_challenge") } /// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster. model Bereich { id String @id @default(cuid()) name String @unique /// Hex-Farbe inklusive Raute, z. B. "#2563eb". farbe String reihenfolge Int @default(0) aktiv Boolean @default(true) /// Tageskapazitaet in Minuten, Montag bis Sonntag. kapazitaetMo Int @default(0) kapazitaetDi Int @default(0) kapazitaetMi Int @default(0) kapazitaetDo Int @default(0) kapazitaetFr Int @default(0) kapazitaetSa Int @default(0) kapazitaetSo Int @default(0) /// Arbeitsfenster als Minuten seit Mitternacht, Europe/Berlin. arbeitsfensterVon Int @default(480) arbeitsfensterBis Int @default(1020) erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt /// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden. standardKalenderkontoId String? standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull) aufgaben Aufgabe[] projekte Projekt[] @@index([aktiv, reihenfolge]) @@map("bereich") } enum AufgabenTyp { AUFGABE IDEE ARBEITSPAKET MEILENSTEIN SPIEGEL } enum AufgabenStatus { OFFEN GEPLANT WARTET ERLEDIGT VERWORFEN } /// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef]. enum Quelle { MANUELL ZAMMAD WARTUNGSPLANER TELEGRAM SPIEGEL MAIL API MCP } enum ProjektStatus { AKTIV PAUSIERT ABGESCHLOSSEN VERWORFEN } /// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine /// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben. model Projekt { id String @id @default(cuid()) name String beschreibung String? status ProjektStatus @default(AKTIV) bereichId String? /// Wunschtermin fuer den Abschluss, rein informativ. zielAm DateTime? erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) aufgaben Aufgabe[] @@unique([name]) @@index([status]) @@index([bereichId]) @@map("projekt") } /// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein. /// /// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied, /// "einsortieren" heisst schlicht: einen Bereich setzen. model Aufgabe { id String @id @default(cuid()) titel String notiz String? typ AufgabenTyp @default(AUFGABE) status AufgabenStatus @default(OFFEN) /// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall. prioritaet Int @default(3) /// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt". aufwandMinuten Int? faelligAm DateTime? erledigtAm DateTime? bereichId String? projektId String? /// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis. quelle Quelle @default(MANUELL) quelleUrl String? /// Kennung beim Ursprungssystem, z. B. die Ticketnummer. quelleRef String? erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) zeitbloecke Zeitblock[] /// Integrationen upserten immer ueber diese beiden Felder. Von Hand /// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte /// als verschieden an, sie behindern sich also nicht. @@unique([quelle, quelleRef]) @@index([status, faelligAm]) @@index([bereichId, status]) @@index([projektId]) @@index([typ, status]) @@map("aufgabe") } /// Unveraenderlicher Nachweis jeder schreibenden Aktion. model AuditEintrag { id String @id @default(cuid()) zeitpunkt DateTime @default(now()) benutzerId String? /// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird. benutzerEmail String aktion String objektTyp String objektId String? /// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs. details Json? instanz String benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) @@index([zeitpunkt]) @@index([objektTyp, objektId]) @@index([benutzerId]) @@map("audit_eintrag") } enum KalenderAnbieter { GRAPH GOOGLE } /// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr. enum ZeigtAls { FREI GEBUCHT MIT_VORBEHALT ABWESEND WOANDERS UNBEKANNT } /// Ein verbundener Kalender bei Microsoft Graph oder Google. model Kalenderkonto { id String @id @default(cuid()) benutzerId String anbieter KalenderAnbieter /// Frei waehlbare Bezeichnung, z. B. "Firma Outlook". anzeigename String /// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen. extKontoId String /// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID. /// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional, /// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst. kalenderId String @default("standard") /// Verschluesselt abgelegt, siehe src/server/krypto.ts. zugriffstoken String? aktualisierungstoken String? tokenGueltigBis DateTime? /// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte. bereiche String? /// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten. deltaVerweis String? letzterAbgleich DateTime? /// Klartextmeldung des letzten Fehlschlags, ohne Tokens. letzterFehler String? /// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine /// Nachricht wert; gemeldet wird erst ab mehreren. fehlversuche Int @default(0) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) termine Termin[] standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") zeitblocks Zeitblock[] /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. @@unique([benutzerId, anbieter, extKontoId, kalenderId]) @@index([benutzerId, aktiv]) @@map("kalenderkonto") } /// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die /// Belegungsrechnung und die Wochenansicht gebraucht wird. model Termin { id String @id @default(cuid()) kalenderkontoId String /// Kennung des Termins beim Anbieter. extId String /// Fuer bedingte Schreibzugriffe ab M4 (If-Match). etag String? betreff String? beginn DateTime ende DateTime ganztaegig Boolean @default(false) zeigtAls ZeigtAls @default(GEBUCHT) aktualisiertAm DateTime @updatedAt kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade) @@unique([kalenderkontoId, extId]) @@index([kalenderkontoId, beginn]) @@index([beginn, ende]) @@map("termin") } /// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht. /// /// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters. /// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert, /// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts. model Zeitblock { id String @id @default(cuid()) aufgabeId String kalenderkontoId String beginn DateTime ende DateTime /// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben. extId String? /// Fuer bedingte Schreibzugriffe (If-Match). etag String? /// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde. hash String? /// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens. letzterFehler String? erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade) kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade) /// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock. @@unique([kalenderkontoId, extId]) @@index([beginn, ende]) @@index([aufgabeId]) @@map("zeitblock") } /// Wie und worueber ein Benutzer benachrichtigt werden will. model Benachrichtigungseinstellung { id String @id @default(cuid()) benutzerId String @unique /// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts. anlaesse String @default("tagesueberblick,faelligkeit") perPush Boolean @default(false) perMail Boolean @default(false) /// Alles als Minuten seit Mitternacht, Europe/Berlin. ueberblickUm Int @default(420) ruheVon Int @default(1200) ruheBis Int @default(420) ruheAmWochenende Boolean @default(false) zeitblockVorlaufMinuten Int @default(10) erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@map("benachrichtigungseinstellung") } /// Ein Browser, der Push-Nachrichten empfangen darf. model PushGeraet { id String @id @default(cuid()) benutzerId String /// Vom Browser vergebene Adresse; eindeutig je Geraet. endpunkt String @unique /// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt. p256dh String auth String bezeichnung String? erstelltAm DateTime @default(now()) zuletztBenutzt DateTime? /// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg. fehlversuche Int @default(0) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@index([benutzerId]) @@map("push_geraet") } /// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen. model Meldungsnachweis { id String @id @default(cuid()) benutzerId String /// anlass:sache:tag, siehe meldungsSchluessel(). schluessel String gesendetAm DateTime @default(now()) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@unique([benutzerId, schluessel]) @@index([gesendetAm]) @@map("meldungsnachweis") }