// ProxLedger – Datenmodell // Entwurfsentscheidungen siehe docs/architektur.md, Abschnitt 3. // Kurz: Ein Host wird über seine agentId identifiziert (nicht über die Server-ID, die sich bei // Hardwaretausch ändert), Rechnungen sind eigene Entitäten mit Positionen, damit Sammelbestellungen // bei Proxmox und Sammelrechnungen an Kunden ohne doppelte Rechnungsnummern abbildbar sind. generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } // ---------------------------------------------------------------- Aufzählungen enum Produkt { PVE PBS PMG } enum SubscriptionStatus { ACTIVE NOTFOUND INVALID EXPIRED SUSPENDED UNKNOWN } enum LaufzeitStatus { VORSCHLAG BESTAETIGT } enum AlertSchwere { INFO WARNUNG GEFAHR } enum NotificationKanal { EMAIL WEBHOOK } enum NotificationStatus { GESENDET FEHLGESCHLAGEN WARTEND } // ---------------------------------------------------------------- Kunden und Standorte model Kunde { id String @id @default(cuid()) name String kundennummer String @unique ansprechpartner String? email String? telefon String? /// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen /// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044). eigenbedarf Boolean @default(false) notizen String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt standorte Standort[] hosts Host[] kundenrechnungen Kundenrechnung[] enrollmentTokens EnrollmentToken[] alerts Alert[] @@index([name]) benutzer Benutzer[] } model Standort { id String @id @default(cuid()) kundeId String name String strasse String? plz String? ort String? notizen String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade) hosts Host[] enrollmentTokens EnrollmentToken[] @@index([kundeId]) } // ---------------------------------------------------------------- Hosts model Host { id String @id @default(cuid()) /// Stabile Kennung, die der Agent bei der Registrierung erzeugt (aus /etc/machine-id abgeleitet). /// Sie bleibt auch dann gleich, wenn sich die Proxmox-Server-ID durch Hardwaretausch ändert. agentId String @unique hostname String fqdn String? kundeId String? standortId String? produkt Produkt produktVersion String? kernel String? /// Aktuelle Server-ID laut letztem Report; die Historie steht in ServerIdHistorie. serverId String? clusterName String? nodeName String? /// Namen aller Knoten des Clusters laut corosync – auch solche ohne eigenen Agent (#022). clusterMitglieder String[] @default([]) ips String[] @default([]) sockets Int? cpuModell String? uptimeSekunden BigInt? paketUpdates Int? agentVersion String? notizen String? // Momentaufnahme der Subscription aus dem letzten Report (für Listen und Filter). subKey String? subStatus SubscriptionStatus @default(UNKNOWN) subLevel String? subProduktname String? subLizenzierteSockets Int? subRegdate DateTime? @db.Date subNextduedate DateTime? @db.Date subChecktime DateTime? /// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine /// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041). archiviertAm DateTime? erstmalsGesehenAm DateTime @default(now()) letzterReportAm DateTime? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) reports Report[] keyHistorie SubscriptionKeyHistorie[] serverIdHistorie ServerIdHistorie[] laufzeiten Laufzeit[] tokens AgentToken[] alerts Alert[] tags Tag[] @relation("HostTags") @@index([kundeId]) @@index([standortId]) @@index([hostname]) @@index([serverId]) @@index([subKey]) @@index([subNextduedate]) @@index([subStatus]) } model Tag { id String @id @default(cuid()) name String @unique farbe String? hosts Host[] @relation("HostTags") } model Report { id String @id @default(cuid()) hostId String /// Vom Agent erzeugte UUID; verhindert doppelte Verarbeitung nachgesendeter Reports. reportId String @unique /// Zeitpunkt der Datenerhebung auf dem Host (kann bei nachgesendeten Reports älter sein). erhobenAm DateTime empfangenAm DateTime @default(now()) subStatus SubscriptionStatus subKey String? subNextduedate DateTime? @db.Date serverId String? hostname String? produktVersion String? kernel String? sockets Int? paketUpdates Int? agentVersion String? ips String[] @default([]) quellIp String? /// Vollständiger Report, damit auch künftige Felder nachträglich auswertbar sind. rohdaten Json host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) @@index([hostId, erhobenAm]) @@index([empfangenAm]) } model SubscriptionKeyHistorie { id String @id @default(cuid()) hostId String key String ersteMeldung DateTime @default(now()) letzteMeldung DateTime @default(now()) host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) @@unique([hostId, key]) @@index([key]) } model ServerIdHistorie { id String @id @default(cuid()) hostId String serverId String ersteMeldung DateTime @default(now()) letzteMeldung DateTime @default(now()) host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) @@unique([hostId, serverId]) @@index([serverId]) } // ---------------------------------------------------------------- Laufzeiten und Rechnungen model Laufzeit { id String @id @default(cuid()) hostId String key String start DateTime @db.Date ende DateTime @db.Date status LaufzeitStatus @default(VORSCHLAG) notizen String? /// Position der Proxmox-Rechnung (Einkauf), die diese Laufzeit bezahlt. proxmoxPositionId String? @unique /// Position der Kundenrechnung (Verkauf), mit der die Laufzeit weiterberechnet wird. kundenPositionId String? @unique erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) proxmoxPosition ProxmoxRechnungPosition? @relation(fields: [proxmoxPositionId], references: [id], onDelete: SetNull) kundenPosition KundenrechnungPosition? @relation(fields: [kundenPositionId], references: [id], onDelete: SetNull) alerts Alert[] @@unique([hostId, start]) @@index([ende]) @@index([key]) } /// Rechnung von Proxmox (Einkauf). Eine Bestellung kann mehrere Keys umfassen. model ProxmoxRechnung { id String @id @default(cuid()) nummer String @unique datum DateTime @db.Date faelligAm DateTime? @db.Date /// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt. nettoBetrag Decimal @db.Decimal(12, 2) /// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037). ustSatz Decimal @default(0.00) @db.Decimal(5, 2) zahlungsart String? bezahlt Boolean @default(false) bezahltAm DateTime? @db.Date notizen String? anhangId String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull) positionen ProxmoxRechnungPosition[] @@index([bezahlt, faelligAm]) @@index([datum]) } model ProxmoxRechnungPosition { id String @id @default(cuid()) rechnungId String bezeichnung String menge Int @default(1) /// Listenpreis dieser Position, wie er auf der Rechnung steht. nettoBetrag Decimal @db.Decimal(12, 2) /// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037). rabatt Decimal? @db.Decimal(12, 2) rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade) laufzeit Laufzeit? @@index([rechnungId]) } /// Rechnung an den Kunden (Verkauf). `erstellt = false` bedeutet: steht noch aus. model Kundenrechnung { id String @id @default(cuid()) kundeId String erstellt Boolean @default(false) nummer String? @unique datum DateTime? @db.Date nettoBetrag Decimal? @db.Decimal(12, 2) ustSatz Decimal @default(19.00) @db.Decimal(5, 2) bezahlt Boolean @default(false) bezahltAm DateTime? @db.Date notizen String? anhangId String? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Cascade) anhang Anhang? @relation(fields: [anhangId], references: [id], onDelete: SetNull) positionen KundenrechnungPosition[] @@index([kundeId]) @@index([erstellt, bezahlt]) } model KundenrechnungPosition { id String @id @default(cuid()) rechnungId String bezeichnung String menge Int @default(1) nettoBetrag Decimal @db.Decimal(12, 2) rechnung Kundenrechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade) laufzeit Laufzeit? @@index([rechnungId]) } model Anhang { id String @id @default(cuid()) dateiname String pfad String mimeTyp String groesse Int sha256 String hochgeladenAm DateTime @default(now()) proxmoxRechnungen ProxmoxRechnung[] kundenrechnungen Kundenrechnung[] } // ---------------------------------------------------------------- Tokens /// Host-spezifischer API-Token. In der Datenbank liegt nur der SHA-256-Hash. model AgentToken { id String @id @default(cuid()) hostId String tokenHash String @unique praefix String bezeichnung String? erstelltAm DateTime @default(now()) letzteNutzungAm DateTime? widerrufenAm DateTime? host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) @@index([hostId]) } /// Token für die Erstregistrierung, optional mit Kunden- und Standortzuordnung. model EnrollmentToken { id String @id @default(cuid()) tokenHash String @unique praefix String bezeichnung String? gueltigBis DateTime? maxNutzungen Int? nutzungen Int @default(0) kundeId String? standortId String? erstelltAm DateTime @default(now()) widerrufenAm DateTime? kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) } // ---------------------------------------------------------------- Warnungen und Benachrichtigungen model Alert { id String @id @default(cuid()) regel String /// Regel + betroffenes Objekt (+ Schwelle). Verhindert doppelte offene Warnungen. fingerprint String @unique schwere AlertSchwere titel String beschreibung String hostId String? kundeId String? laufzeitId String? geoeffnetAm DateTime @default(now()) quittiertAm DateTime? erledigtAm DateTime? host Host? @relation(fields: [hostId], references: [id], onDelete: Cascade) kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Cascade) laufzeit Laufzeit? @relation(fields: [laufzeitId], references: [id], onDelete: Cascade) benachrichtigungen Benachrichtigung[] @@index([erledigtAm]) @@index([hostId]) @@index([kundeId]) @@index([regel]) } model Benachrichtigung { id String @id @default(cuid()) alertId String? kanal NotificationKanal empfaenger String betreff String? status NotificationStatus @default(WARTEND) fehler String? versendetAm DateTime? erstelltAm DateTime @default(now()) alert Alert? @relation(fields: [alertId], references: [id], onDelete: SetNull) @@index([alertId]) @@index([erstelltAm]) } /// Schwellwerte je Warnregel, z. B. { "tage": [30, 14, 7] }. model WarnregelEinstellung { id String @id @default(cuid()) regel String @unique aktiv Boolean @default(true) schwellwerte Json @default("{}") geaendertAm DateTime @updatedAt } model Benachrichtigungskanal { id String @id @default(cuid()) typ NotificationKanal name String aktiv Boolean @default(true) konfiguration Json erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt } // ---------------------------------------------------------------- Benutzer und Protokoll /// Alle Benutzer sind Administratoren; eine Nur-Lese-Rolle ist bewusst nicht vorgesehen. /// Was jemand darf. ADMIN alles, TECHNIKER alles Fachliche, KUNDE nur die eigenen Daten (#027). enum Rolle { ADMIN TECHNIKER KUNDE } model Benutzer { id String @id @default(cuid()) email String @unique name String passwortHash String? oidcSubject String? @unique /// Bestehende Konten waren vor der Einführung der Rollen uneingeschränkt – daher ADMIN. rolle Rolle @default(ADMIN) /// Nur für die Rolle KUNDE: an welchen Kunden das Konto gebunden ist. kundeId String? aktiv Boolean @default(true) letzterLoginAm DateTime? /// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024). sitzungenUngueltigAb DateTime? erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt auditEintraege AuditEintrag[] kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: SetNull) passkeys Passkey[] @@index([kundeId]) } /// Ein registrierter Passkey (WebAuthn). Ein Konto darf mehrere haben – Rechner und Telefon (#028). model Passkey { id String @id @default(cuid()) benutzerId String /// Kennung des Schlüssels, base64url wie vom Browser geliefert. credentialId String @unique publicKey Bytes zaehler BigInt @default(0) transports String[] @default([]) bezeichnung String erstelltAm DateTime @default(now()) zuletztAm DateTime? benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@index([benutzerId]) } model AuditEintrag { id String @id @default(cuid()) benutzerId String? aktion String entitaet String entitaetId String? aenderung Json? ipAdresse String? erstelltAm DateTime @default(now()) benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) @@index([entitaet, entitaetId]) @@index([erstelltAm]) } /// Freie Systemeinstellungen (z. B. SMTP-Konfiguration, Anzeigeoptionen). model Einstellung { schluessel String @id wert Json geaendertAm DateTime @updatedAt }