// 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[] telegramChats TelegramChat[] apiTokens ApiToken[] @@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? /// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst /// die Instanz mit Titel – alles andere bleibt, wo es ist. spiegeln Boolean @default(false) 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) perTelegram 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") } /// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt. /// /// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede /// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen /// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht. model Integrationszustand { id String @id @default(cuid()) quelle Quelle @unique /// Woher es weitergeht. Bedeutung haengt an der Quelle. merker String? letzterLauf DateTime? letzterErfolg DateTime? letzterFehler String? /// Fehlschlaege in Folge; bei Erfolg wieder null. fehlversuche Int @default(0) erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt @@map("integrationszustand") } /// Dienstliche Belegung, die aus der anderen Instanz kommt. /// /// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht /// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht. model Spiegelbelegung { id String @id @default(cuid()) /// Kennung des Zeitblocks in der sendenden Instanz. quelleRef String @unique beginn DateTime ende DateTime empfangenAm DateTime @default(now()) @@index([beginn]) @@map("spiegelbelegung") } /// Ein Telegram-Chat, der mit dem Planer reden darf. /// /// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten /// oder abgreifen, die Chatkennung ist der zweite Riegel. model TelegramChat { id String @id @default(cuid()) /// Chatkennung bei Telegram; kann negativ sein (Gruppen). chatId BigInt @unique bezeichnung String? benutzerId String? /// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben? meldungen Boolean @default(true) erstelltAm DateTime @default(now()) zuletztBenutzt DateTime? benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) @@index([benutzerId]) @@map("telegram_chat") } /// Einmalcode, mit dem sich ein Telegram-Chat anmeldet. /// /// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie /// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten. /// Genau das soll er nicht. model TelegramKopplung { id String @id @default(cuid()) code String @unique benutzerId String gueltigBis DateTime erstelltAm DateTime @default(now()) @@index([gueltigBis]) @@map("telegram_kopplung") } /// Zugangstoken fuer die oeffentliche Schnittstelle. /// /// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim /// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert, /// legt ein neues an, statt das alte irgendwo nachzuschlagen. model ApiToken { id String @id @default(cuid()) benutzerId String name String /// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar. praefix String @unique /// SHA-256 des vollstaendigen Tokens. hash String @unique /// Kommagetrennt, siehe src/lib/api.ts. rechte String gueltigBis DateTime? letzteNutzung DateTime? /// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll. widerrufenAm DateTime? erstelltAm DateTime @default(now()) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@index([benutzerId]) @@map("api_token") }