Zwei Eintraege desselben Namens beim selben Kunden entstehen, wenn sich die Kennung aus /etc/machine-id aendert - Neuinstallation, geklonter Container, zurueckgespielte Sicherung. Dieselbe Maschine meldet sich dann als neuer Host, der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind aber genauso moeglich. Neue dreizehnte Warnregel meldet den Fall und nennt die IP-Adressen zum Unterscheiden; gleiche Namen bei verschiedenen Kunden bleiben still. Dazu die bis dahin fehlende Moeglichkeit, einen Host loszuwerden: Stilllegen blendet ihn aus Listen, Zahlen, Suche und Warnungen aus und schliesst seine offenen Warnungen, laesst Laufzeiten und Rechnungen aber unberuehrt. Endgueltig loeschen geht nur ohne Laufzeiten - Buchhaltung wirft man nicht weg. Kein automatisches Zusammenfuehren: Es muesste raten, welcher Eintrag welche Rechnung erbt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
531 lines
15 KiB
Plaintext
531 lines
15 KiB
Plaintext
// 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?
|
||
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
|
||
}
|