Files
ProxLedger/prisma/schema.prisma
T
florian.wildeandClaude Opus 5 3657b60d16 feat(hosts): Doppelte Hosts erkennen und stilllegen (#041)
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>
2026-10-03 17:57:18 +02:00

531 lines
15 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}