Drei Tabellen: die Einstellung je Benutzer, angemeldete Push-Geraete und ein Nachweis je verschickter Meldung. Der Nachweis ist das Gedaechtnis gegen Wiederholungen — ohne ihn kaeme dieselbe Sache bei jedem Durchlauf erneut. Kalenderkonten bekommen einen Zaehler fuer Fehlversuche in Folge, damit ein Abgleichfehler erst nach mehreren Versuchen gemeldet wird. VAPID- und SMTP-Werte sind freiwillig: fehlen sie, laeuft der Planer ohne den jeweiligen Weg weiter und sagt es in den Einstellungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
451 lines
13 KiB
Plaintext
451 lines
13 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[]
|
||
|
||
@@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")
|
||
}
|