Gespeichert wird nur der Hash eines Tokens. Der Klartext existiert genau einmal — beim Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; es gibt keinen Grund, es zurueckzulesen, und jeden Grund, es nicht zu koennen. Drei Rechte, und keines schliesst ein anderes ein. Ein Dienst, der nur meldet "hier ist etwas Neues", braucht den Bestand nicht zu sehen — genau das ist der haeufigste Fall und der, bei dem ein mitgelesener Token am wenigsten anrichtet. Der sichtbare Anfang "planer_..." ist Absicht: Wer so einen Wert versehentlich in ein oeffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen. 30 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
573 lines
16 KiB
Plaintext
573 lines
16 KiB
Plaintext
// 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")
|
||
}
|