Merge branch 'phase/003-datenmodell'

This commit is contained in:
2026-09-17 20:32:04 +02:00
22 changed files with 4121 additions and 36 deletions
+16
View File
@@ -0,0 +1,16 @@
# Datenbank (lokal über docker-compose.dev.yml, Port 5433)
DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger?schema=public"
# Eigene Datenbank für automatisierte Tests
TEST_DATABASE_URL="postgresql://proxledger:proxledger@localhost:5433/proxledger_test?schema=public"
# Lokaler Administrator, wird vom Seed angelegt
ADMIN_EMAIL="admin@example.de"
ADMIN_NAME="Administrator"
ADMIN_PASSWORD="bitte-aendern"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000"
# Demo-Daten beim Seed anlegen (1 = ja)
SEED_DEMO="0"
+2
View File
@@ -51,3 +51,5 @@ Thumbs.db
# Vercel / Sonstiges
.vercel
*.pem
/src/generated/prisma
+2
View File
@@ -4,3 +4,5 @@ coverage
docs
*.md
package-lock.json
src/generated
prisma/migrations
+3
View File
@@ -14,6 +14,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Klickbarer Prototyp mit Beispieldaten: Dashboard, Kunden- und Host-Ansichten, Rechnungen, Jahresbahn (#002)
- ProxLedger-Logo als SVG-Komponente, Farbschema nach Vorgabe inklusive Dark-Mode (#002)
- Globale Suche über Key, Host, Kunde und Rechnungsnummer mit Strg+K (#002)
- Datenmodell mit Prisma: Kunden, Standorte, Hosts, Reports, Key- und Server-ID-Historie, Laufzeiten, Proxmox- und Kundenrechnungen mit Positionen, Tokens, Warnungen, Benachrichtigungen, Benutzer und Audit-Log (#003)
- Erste Migration, Seed für Administrator, Warnregeln und Demo-Daten (#003)
- Docker-Compose für die Entwicklungsdatenbank und Testdatenbank-Setup für Vitest (#003)
### Geändert
+10 -5
View File
@@ -2,21 +2,22 @@
## Zeitübersicht
**Gesamt:** 0 h 28 min
**Gesamt:** 0 h 53 min
| Issue | Zeit |
|---|---|
| #001 Projekt-Setup | 0 h 08 min |
| #002 Klickbarer Prototyp | 0 h 20 min |
| #003 Datenmodell und Migrationen | 0 h 25 min |
| Meilenstein | Zeit |
|---|---|
| v0.1.0 | 0 h 28 min |
| v0.1.0 | 0 h 53 min |
---
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001 Projekt-Setup, #002 Klickbarer Prototyp
**Issue:** #001 Projekt-Setup, #002 Klickbarer Prototyp, #003 Datenmodell
**Version:** 0.1.0-dev
### Erledigt
@@ -27,6 +28,7 @@
- #001 abgeschlossen: Next.js 16 (App Router, TypeScript strict), AdminLTE-CSS, Bootstrap Icons, zod, ESLint/Prettier/Vitest; Lint, Typecheck, Test und Build grün
- #002 abgeschlossen: Prototyp mit Beispieldaten – Layout (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als SVG-Komponente, Theme mit Farbvorgaben und Dark-Mode, Dashboard, Kunden (Liste und Detail), Hosts (Liste, Detail, nicht zugeordnet), Rechnungen, Jahresbahn, Einstellungsseiten, globale Suche mit Strg+K
- Screenshots aller Seiten aufgenommen und in `docs/anleitung/bilder/` abgelegt
- #003 abgeschlossen: Prisma-Schema mit 23 Modellen, erste Migration, Seed (Administrator, 10 Warnregeln, Demo-Daten), Testdatenbank-Setup für Vitest, 3 Schema-Tests
### Entscheidungen
- **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig).
@@ -36,11 +38,14 @@
- **Rechnungen mit Positionen:** Proxmox-Sammelbestellungen und Sammelrechnungen an Kunden sollen ohne doppelte Rechnungsnummern abbildbar sein.
- **Eigener Worker-Prozess** für zeitgesteuerte Warnregeln und Benachrichtigungen, unabhängig von Seitenaufrufen.
- **Prototyp direkt im Next.js-Projekt**, damit die Komponenten weiterverwendet werden.
- **Prisma 7.10.0 fest gesetzt:** Der npm-Tag `latest` zeigt derzeit auf 8.0.0-rc, ein Release Candidate gehört nicht in ein neues Projekt.
- **Seed nutzt die Prototyp-Beispieldaten**, damit die Oberfläche ab #006 dieselben Fälle zeigt wie der Prototyp.
- **Keine Nur-Lese-Rolle** (Rückmeldung Florian): alle Benutzer sind Administratoren.
### Probleme / offene Fragen
- Docker ist nicht installiert und `sudo` erfordert ein Passwort → Installation muss manuell erfolgen (wird ab #003 gebraucht).
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei).
- Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist.
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt
- Rückmeldung zum Prototyp abwarten, dann #003 Datenmodell (braucht PostgreSQL per Docker)
- #004 Agent-API: Enrollment, Report-Endpunkt, Token-Hashing, Rate-Limit, Tests
+8 -1
View File
@@ -11,12 +11,19 @@ Voraussetzungen: Node.js ≥ 24, Docker (für PostgreSQL ab #003).
```bash
npm install
npm run db:up # PostgreSQL im Container (Port 5433)
cp .env.example .env
npm run db:migrate # Schema anlegen
npm run db:seed # Administrator, Warnregeln, mit SEED_DEMO=1 auch Demo-Daten
npm run dev # http://localhost:3000
npm run lint
npm run typecheck
npm test
npm test # legt bei Bedarf die Testdatenbank an
```
Weitere Skripte: `npm run db:studio` (Datenbank-Oberfläche), `npm run db:down` (Container stoppen).
## Dokumentation
- [Architektur- und Umsetzungsplan](docs/architektur.md)
+23
View File
@@ -0,0 +1,23 @@
# PostgreSQL für die lokale Entwicklung. Die vollständige Compose-Datei
# (App + Worker + Datenbank) entsteht in #014.
services:
postgres:
image: postgres:17-alpine
container_name: proxledger-postgres-dev
restart: unless-stopped
environment:
POSTGRES_USER: proxledger
POSTGRES_PASSWORD: proxledger
POSTGRES_DB: proxledger
ports:
- "5433:5432"
volumes:
- proxledger-dev-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxledger"]
interval: 5s
timeout: 3s
retries: 10
volumes:
proxledger-dev-db:
+15 -11
View File
@@ -1,24 +1,24 @@
# #003 Datenmodell und Migrationen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** backend, datenbank
**Geschätzt:** 5 h **Tatsächlich:** –
**Geschätzt:** 5 h **Tatsächlich:** 0 h 25 min
## Ziel
Vollständiges Prisma-Schema gemäß `docs/architektur.md` Abschnitt 3, erste Migration, Seed mit Admin-User und optionalen Demo-Daten.
## Aufgaben
- [ ] Docker-Compose für lokale Entwicklung (nur PostgreSQL)
- [ ] Prisma-Schema: Customer, Site, Host, Tag, Report, SubscriptionKey, ServerIdHistory, SubscriptionPeriod, PurchaseInvoice(+Item), SalesInvoice(+Item), Attachment, AgentToken, EnrollmentToken, Alert, Notification, AlertRuleSetting, NotificationChannel, User, AuditLog, Setting
- [ ] Indizes für Suche (Key, Server-ID, Hostname, IP, Rechnungsnummer)
- [ ] Erste Migration
- [ ] Seed: Admin-User aus Umgebungsvariablen, Standard-Warnregeln, optional Demo-Daten (`SEED_DEMO=1`)
- [ ] DB-Client-Singleton, Test-Datenbank-Setup für Vitest
- [x] Docker-Compose für lokale Entwicklung (nur PostgreSQL)
- [x] Prisma-Schema: Customer, Site, Host, Tag, Report, SubscriptionKey, ServerIdHistory, SubscriptionPeriod, PurchaseInvoice(+Item), SalesInvoice(+Item), Attachment, AgentToken, EnrollmentToken, Alert, Notification, AlertRuleSetting, NotificationChannel, User, AuditLog, Setting
- [x] Indizes für Suche (Key, Server-ID, Hostname, IP, Rechnungsnummer)
- [x] Erste Migration
- [x] Seed: Admin-User aus Umgebungsvariablen, Standard-Warnregeln, optional Demo-Daten (`SEED_DEMO=1`)
- [x] DB-Client-Singleton, Test-Datenbank-Setup für Vitest
## Akzeptanzkriterien
- [ ] `npx prisma migrate dev` läuft auf leerer DB durch
- [ ] `npx prisma db seed` legt Admin und Standard-Regeln an, idempotent
- [ ] Beträge als Decimal, Zeitstempel in UTC
- [x] `npx prisma migrate dev` läuft auf leerer DB durch
- [x] `npx prisma db seed` legt Admin und Standard-Regeln an, idempotent
- [x] Beträge als Decimal, Zeitstempel in UTC
## Abhängigkeiten
#001
@@ -26,3 +26,7 @@ Vollständiges Prisma-Schema gemäß `docs/architektur.md` Abschnitt 3, erste Mi
## Notizen / Entscheidungen
- Host-Identität über Agent-Token/`agentId`, nicht über Server-ID (siehe Architektur).
- Rechnungen als eigene Entitäten mit Positionen, damit Sammelbestellungen und Sammelrechnungen abbildbar sind.
- Prisma 7.10.0 statt der npm-Kennzeichnung „latest“: dort liegt derzeit ein Release Candidate (8.0.0-rc).
- Prisma 7 benötigt einen Treiber-Adapter; im Einsatz ist `@prisma/adapter-pg`.
- Der Seed legt Demo-Daten aus `src/mock/data.ts` an, damit Prototyp und Datenbank dieselben Beispiele zeigen. Die Sammelrechnung PX-2025-88213 deckt bewusst zwei Laufzeiten ab.
- Passwörter werden mit argon2id gehasht (`@node-rs/argon2`, vorkompiliert – kein Build-Toolchain nötig).
+3 -3
View File
@@ -6,8 +6,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|---|---|---|---|---|---|
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 3 h | 0 h 08 min |
| [#002](002-prototyp.md) | Klickbarer Prototyp | erledigt | v0.1.0 | 8 h | 0 h 20 min |
| [#003](003-datenmodell.md) | Datenmodell und Migrationen | in Arbeit | v0.1.0 | 5 h | – |
| [#004](004-agent-api.md) | Agent-API | offen | v0.1.0 | 6 h | – |
| [#003](003-datenmodell.md) | Datenmodell und Migrationen | erledigt | v0.1.0 | 5 h | 0 h 25 min |
| [#004](004-agent-api.md) | Agent-API | in Arbeit | v0.1.0 | 6 h | – |
| [#005](005-agent-skript.md) | Agent-Skript | offen | v0.2.0 | 8 h | – |
| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | offen | v0.3.0 | 6 h | – |
| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | offen | v0.4.0 | 8 h | – |
@@ -20,4 +20,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – |
| [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – |
| [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – |
| | **Summe** | | | **106 h** | **0 h 28 min** |
| | **Summe** | | | **106 h** | **0 h 53 min** |
+1 -1
View File
@@ -7,7 +7,7 @@ const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
prettier,
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts"]),
globalIgnores([".next/**", "out/**", "build/**", "coverage/**", "next-env.d.ts", "src/generated/**"]),
]);
export default eslintConfig;
+2461 -14
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -10,9 +10,19 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write ."
"format": "prettier --write .",
"db:up": "docker compose -f docker-compose.dev.yml up -d",
"db:down": "docker compose -f docker-compose.dev.yml down",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"postinstall": "prisma generate"
},
"dependencies": {
"@node-rs/argon2": "^2.2.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"admin-lte": "^4.9.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
@@ -25,10 +35,13 @@
"@types/node": "^24.13.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7",
"prisma": "^7.10.0",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.1"
},
@@ -0,0 +1,580 @@
-- CreateEnum
CREATE TYPE "Produkt" AS ENUM ('PVE', 'PBS', 'PMG');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'NOTFOUND', 'INVALID', 'EXPIRED', 'SUSPENDED', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "LaufzeitStatus" AS ENUM ('VORSCHLAG', 'BESTAETIGT');
-- CreateEnum
CREATE TYPE "AlertSchwere" AS ENUM ('INFO', 'WARNUNG', 'GEFAHR');
-- CreateEnum
CREATE TYPE "NotificationKanal" AS ENUM ('EMAIL', 'WEBHOOK');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('GESENDET', 'FEHLGESCHLAGEN', 'WARTEND');
-- CreateTable
CREATE TABLE "Kunde" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kundennummer" TEXT NOT NULL,
"ansprechpartner" TEXT,
"email" TEXT,
"telefon" TEXT,
"notizen" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kunde_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Standort" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"strasse" TEXT,
"plz" TEXT,
"ort" TEXT,
"notizen" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Standort_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Host" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"hostname" TEXT NOT NULL,
"fqdn" TEXT,
"kundeId" TEXT,
"standortId" TEXT,
"produkt" "Produkt" NOT NULL,
"produktVersion" TEXT,
"kernel" TEXT,
"serverId" TEXT,
"clusterName" TEXT,
"nodeName" TEXT,
"ips" TEXT[] DEFAULT ARRAY[]::TEXT[],
"sockets" INTEGER,
"cpuModell" TEXT,
"uptimeSekunden" BIGINT,
"paketUpdates" INTEGER,
"agentVersion" TEXT,
"notizen" TEXT,
"subKey" TEXT,
"subStatus" "SubscriptionStatus" NOT NULL DEFAULT 'UNKNOWN',
"subLevel" TEXT,
"subProduktname" TEXT,
"subLizenzierteSockets" INTEGER,
"subRegdate" DATE,
"subNextduedate" DATE,
"subChecktime" TIMESTAMP(3),
"erstmalsGesehenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzterReportAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Host_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"farbe" TEXT,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"erhobenAm" TIMESTAMP(3) NOT NULL,
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subStatus" "SubscriptionStatus" NOT NULL,
"subKey" TEXT,
"subNextduedate" DATE,
"serverId" TEXT,
"hostname" TEXT,
"produktVersion" TEXT,
"kernel" TEXT,
"sockets" INTEGER,
"paketUpdates" INTEGER,
"agentVersion" TEXT,
"ips" TEXT[] DEFAULT ARRAY[]::TEXT[],
"quellIp" TEXT,
"rohdaten" JSONB NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SubscriptionKeyHistorie" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"ersteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SubscriptionKeyHistorie_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerIdHistorie" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"ersteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteMeldung" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ServerIdHistorie_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Laufzeit" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"start" DATE NOT NULL,
"ende" DATE NOT NULL,
"status" "LaufzeitStatus" NOT NULL DEFAULT 'VORSCHLAG',
"notizen" TEXT,
"proxmoxPositionId" TEXT,
"kundenPositionId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Laufzeit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProxmoxRechnung" (
"id" TEXT NOT NULL,
"nummer" TEXT NOT NULL,
"datum" DATE NOT NULL,
"faelligAm" DATE,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
"ustSatz" DECIMAL(5,2) NOT NULL DEFAULT 19.00,
"rabatt" DECIMAL(12,2),
"zahlungsart" TEXT,
"bezahlt" BOOLEAN NOT NULL DEFAULT false,
"bezahltAm" DATE,
"notizen" TEXT,
"anhangId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProxmoxRechnung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProxmoxRechnungPosition" (
"id" TEXT NOT NULL,
"rechnungId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
CONSTRAINT "ProxmoxRechnungPosition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Kundenrechnung" (
"id" TEXT NOT NULL,
"kundeId" TEXT NOT NULL,
"erstellt" BOOLEAN NOT NULL DEFAULT false,
"nummer" TEXT,
"datum" DATE,
"nettoBetrag" DECIMAL(12,2),
"ustSatz" DECIMAL(5,2) NOT NULL DEFAULT 19.00,
"bezahlt" BOOLEAN NOT NULL DEFAULT false,
"bezahltAm" DATE,
"notizen" TEXT,
"anhangId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kundenrechnung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KundenrechnungPosition" (
"id" TEXT NOT NULL,
"rechnungId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"nettoBetrag" DECIMAL(12,2) NOT NULL,
CONSTRAINT "KundenrechnungPosition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Anhang" (
"id" TEXT NOT NULL,
"dateiname" TEXT NOT NULL,
"pfad" TEXT NOT NULL,
"mimeTyp" TEXT NOT NULL,
"groesse" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"hochgeladenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anhang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentToken" (
"id" TEXT NOT NULL,
"hostId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"letzteNutzungAm" TIMESTAMP(3),
"widerrufenAm" TIMESTAMP(3),
CONSTRAINT "AgentToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EnrollmentToken" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"bezeichnung" TEXT,
"gueltigBis" TIMESTAMP(3),
"maxNutzungen" INTEGER,
"nutzungen" INTEGER NOT NULL DEFAULT 0,
"kundeId" TEXT,
"standortId" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"widerrufenAm" TIMESTAMP(3),
CONSTRAINT "EnrollmentToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Alert" (
"id" TEXT NOT NULL,
"regel" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"schwere" "AlertSchwere" NOT NULL,
"titel" TEXT NOT NULL,
"beschreibung" TEXT NOT NULL,
"hostId" TEXT,
"kundeId" TEXT,
"laufzeitId" TEXT,
"geoeffnetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"quittiertAm" TIMESTAMP(3),
"erledigtAm" TIMESTAMP(3),
CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benachrichtigung" (
"id" TEXT NOT NULL,
"alertId" TEXT,
"kanal" "NotificationKanal" NOT NULL,
"empfaenger" TEXT NOT NULL,
"betreff" TEXT,
"status" "NotificationStatus" NOT NULL DEFAULT 'WARTEND',
"fehler" TEXT,
"versendetAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Benachrichtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WarnregelEinstellung" (
"id" TEXT NOT NULL,
"regel" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"schwellwerte" JSONB NOT NULL DEFAULT '{}',
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WarnregelEinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benachrichtigungskanal" (
"id" TEXT NOT NULL,
"typ" "NotificationKanal" NOT NULL,
"name" TEXT NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"konfiguration" JSONB NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benachrichtigungskanal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Benutzer" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwortHash" TEXT,
"oidcSubject" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"letzterLoginAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditEintrag" (
"id" TEXT NOT NULL,
"benutzerId" TEXT,
"aktion" TEXT NOT NULL,
"entitaet" TEXT NOT NULL,
"entitaetId" TEXT,
"aenderung" JSONB,
"ipAdresse" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEintrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Einstellung" (
"schluessel" TEXT NOT NULL,
"wert" JSONB NOT NULL,
"geaendertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("schluessel")
);
-- CreateTable
CREATE TABLE "_HostTags" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_HostTags_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Kunde_kundennummer_key" ON "Kunde"("kundennummer");
-- CreateIndex
CREATE INDEX "Kunde_name_idx" ON "Kunde"("name");
-- CreateIndex
CREATE INDEX "Standort_kundeId_idx" ON "Standort"("kundeId");
-- CreateIndex
CREATE UNIQUE INDEX "Host_agentId_key" ON "Host"("agentId");
-- CreateIndex
CREATE INDEX "Host_kundeId_idx" ON "Host"("kundeId");
-- CreateIndex
CREATE INDEX "Host_standortId_idx" ON "Host"("standortId");
-- CreateIndex
CREATE INDEX "Host_hostname_idx" ON "Host"("hostname");
-- CreateIndex
CREATE INDEX "Host_serverId_idx" ON "Host"("serverId");
-- CreateIndex
CREATE INDEX "Host_subKey_idx" ON "Host"("subKey");
-- CreateIndex
CREATE INDEX "Host_subNextduedate_idx" ON "Host"("subNextduedate");
-- CreateIndex
CREATE INDEX "Host_subStatus_idx" ON "Host"("subStatus");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Report_reportId_key" ON "Report"("reportId");
-- CreateIndex
CREATE INDEX "Report_hostId_erhobenAm_idx" ON "Report"("hostId", "erhobenAm");
-- CreateIndex
CREATE INDEX "Report_empfangenAm_idx" ON "Report"("empfangenAm");
-- CreateIndex
CREATE INDEX "SubscriptionKeyHistorie_key_idx" ON "SubscriptionKeyHistorie"("key");
-- CreateIndex
CREATE UNIQUE INDEX "SubscriptionKeyHistorie_hostId_key_key" ON "SubscriptionKeyHistorie"("hostId", "key");
-- CreateIndex
CREATE INDEX "ServerIdHistorie_serverId_idx" ON "ServerIdHistorie"("serverId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerIdHistorie_hostId_serverId_key" ON "ServerIdHistorie"("hostId", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_proxmoxPositionId_key" ON "Laufzeit"("proxmoxPositionId");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_kundenPositionId_key" ON "Laufzeit"("kundenPositionId");
-- CreateIndex
CREATE INDEX "Laufzeit_ende_idx" ON "Laufzeit"("ende");
-- CreateIndex
CREATE INDEX "Laufzeit_key_idx" ON "Laufzeit"("key");
-- CreateIndex
CREATE UNIQUE INDEX "Laufzeit_hostId_start_key" ON "Laufzeit"("hostId", "start");
-- CreateIndex
CREATE UNIQUE INDEX "ProxmoxRechnung_nummer_key" ON "ProxmoxRechnung"("nummer");
-- CreateIndex
CREATE INDEX "ProxmoxRechnung_bezahlt_faelligAm_idx" ON "ProxmoxRechnung"("bezahlt", "faelligAm");
-- CreateIndex
CREATE INDEX "ProxmoxRechnung_datum_idx" ON "ProxmoxRechnung"("datum");
-- CreateIndex
CREATE INDEX "ProxmoxRechnungPosition_rechnungId_idx" ON "ProxmoxRechnungPosition"("rechnungId");
-- CreateIndex
CREATE UNIQUE INDEX "Kundenrechnung_nummer_key" ON "Kundenrechnung"("nummer");
-- CreateIndex
CREATE INDEX "Kundenrechnung_kundeId_idx" ON "Kundenrechnung"("kundeId");
-- CreateIndex
CREATE INDEX "Kundenrechnung_erstellt_bezahlt_idx" ON "Kundenrechnung"("erstellt", "bezahlt");
-- CreateIndex
CREATE INDEX "KundenrechnungPosition_rechnungId_idx" ON "KundenrechnungPosition"("rechnungId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentToken_tokenHash_key" ON "AgentToken"("tokenHash");
-- CreateIndex
CREATE INDEX "AgentToken_hostId_idx" ON "AgentToken"("hostId");
-- CreateIndex
CREATE UNIQUE INDEX "EnrollmentToken_tokenHash_key" ON "EnrollmentToken"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "Alert_fingerprint_key" ON "Alert"("fingerprint");
-- CreateIndex
CREATE INDEX "Alert_erledigtAm_idx" ON "Alert"("erledigtAm");
-- CreateIndex
CREATE INDEX "Alert_hostId_idx" ON "Alert"("hostId");
-- CreateIndex
CREATE INDEX "Alert_kundeId_idx" ON "Alert"("kundeId");
-- CreateIndex
CREATE INDEX "Alert_regel_idx" ON "Alert"("regel");
-- CreateIndex
CREATE INDEX "Benachrichtigung_alertId_idx" ON "Benachrichtigung"("alertId");
-- CreateIndex
CREATE INDEX "Benachrichtigung_erstelltAm_idx" ON "Benachrichtigung"("erstelltAm");
-- CreateIndex
CREATE UNIQUE INDEX "WarnregelEinstellung_regel_key" ON "WarnregelEinstellung"("regel");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_email_key" ON "Benutzer"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Benutzer_oidcSubject_key" ON "Benutzer"("oidcSubject");
-- CreateIndex
CREATE INDEX "AuditEintrag_entitaet_entitaetId_idx" ON "AuditEintrag"("entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "AuditEintrag_erstelltAm_idx" ON "AuditEintrag"("erstelltAm");
-- CreateIndex
CREATE INDEX "_HostTags_B_index" ON "_HostTags"("B");
-- AddForeignKey
ALTER TABLE "Standort" ADD CONSTRAINT "Standort_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "Standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SubscriptionKeyHistorie" ADD CONSTRAINT "SubscriptionKeyHistorie_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerIdHistorie" ADD CONSTRAINT "ServerIdHistorie_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_proxmoxPositionId_fkey" FOREIGN KEY ("proxmoxPositionId") REFERENCES "ProxmoxRechnungPosition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Laufzeit" ADD CONSTRAINT "Laufzeit_kundenPositionId_fkey" FOREIGN KEY ("kundenPositionId") REFERENCES "KundenrechnungPosition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProxmoxRechnung" ADD CONSTRAINT "ProxmoxRechnung_anhangId_fkey" FOREIGN KEY ("anhangId") REFERENCES "Anhang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProxmoxRechnungPosition" ADD CONSTRAINT "ProxmoxRechnungPosition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "ProxmoxRechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kundenrechnung" ADD CONSTRAINT "Kundenrechnung_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Kundenrechnung" ADD CONSTRAINT "Kundenrechnung_anhangId_fkey" FOREIGN KEY ("anhangId") REFERENCES "Anhang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KundenrechnungPosition" ADD CONSTRAINT "KundenrechnungPosition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "Kundenrechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentToken" ADD CONSTRAINT "AgentToken_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EnrollmentToken" ADD CONSTRAINT "EnrollmentToken_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EnrollmentToken" ADD CONSTRAINT "EnrollmentToken_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "Standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_kundeId_fkey" FOREIGN KEY ("kundeId") REFERENCES "Kunde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_laufzeitId_fkey" FOREIGN KEY ("laufzeitId") REFERENCES "Laufzeit"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Benachrichtigung" ADD CONSTRAINT "Benachrichtigung_alertId_fkey" FOREIGN KEY ("alertId") REFERENCES "Alert"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditEintrag" ADD CONSTRAINT "AuditEintrag_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HostTags" ADD CONSTRAINT "_HostTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Host"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HostTags" ADD CONSTRAINT "_HostTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+484
View File
@@ -0,0 +1,484 @@
// 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])
}
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?
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?
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
nettoBetrag Decimal @db.Decimal(12, 2)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
rabatt Decimal? @db.Decimal(12, 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)
nettoBetrag 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.
model Benutzer {
id String @id @default(cuid())
email String @unique
name String
passwortHash String?
oidcSubject String? @unique
aktiv Boolean @default(true)
letzterLoginAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
auditEintraege AuditEintrag[]
}
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
}
+296
View File
@@ -0,0 +1,296 @@
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import type { Prisma } from "../src/generated/prisma/client";
import type { Produkt, SubscriptionStatus } from "../src/generated/prisma/enums";
import { alerts, hosts, kunden, laufzeiten, reports } from "../src/mock/data";
import type { Laufzeit as MockLaufzeit } from "../src/mock/types";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
/** Standardwerte der Warnregeln; Schwellwerte sind später in der Oberfläche änderbar (#011). */
const WARNREGELN: { regel: string; schwellwerte: Prisma.InputJsonValue }[] = [
{ regel: "subscription-laeuft-ab", schwellwerte: { tage: [30, 14, 7] } },
{ regel: "status-nicht-aktiv", schwellwerte: {} },
{ regel: "sockets-abweichung", schwellwerte: {} },
{ regel: "keine-meldung", schwellwerte: { stunden: 48 } },
{ regel: "serverid-geaendert", schwellwerte: {} },
{ regel: "key-mehrfach-gemeldet", schwellwerte: {} },
{ regel: "host-ohne-kunde", schwellwerte: {} },
{ regel: "proxmox-rechnung-faellig", schwellwerte: { tage: 7 } },
{ regel: "kundenrechnung-fehlt", schwellwerte: {} },
{ regel: "kundenrechnung-unbezahlt", schwellwerte: { tage: 30 } },
];
async function seedAdmin() {
const email = process.env.ADMIN_EMAIL ?? "admin@example.de";
const name = process.env.ADMIN_NAME ?? "Administrator";
const passwort = process.env.ADMIN_PASSWORD;
if (!passwort) {
console.warn("ADMIN_PASSWORD ist nicht gesetzt – der Administrator wird ohne Passwort angelegt.");
}
const passwortHash = passwort ? await hash(passwort) : null;
await prisma.benutzer.upsert({
where: { email },
// Ein vorhandenes Passwort wird nicht überschrieben, damit der Seed gefahrlos wiederholbar ist.
update: { name },
create: { email, name, passwortHash },
});
console.log(`Administrator: ${email}`);
}
async function seedWarnregeln() {
for (const regel of WARNREGELN) {
await prisma.warnregelEinstellung.upsert({
where: { regel: regel.regel },
update: {},
create: { regel: regel.regel, aktiv: true, schwellwerte: regel.schwellwerte },
});
}
console.log(`${WARNREGELN.length} Warnregeln vorbereitet`);
}
const PRODUKT: Record<string, Produkt> = { pve: "PVE", pbs: "PBS", pmg: "PMG" };
const STATUS: Record<string, SubscriptionStatus> = {
active: "ACTIVE",
notfound: "NOTFOUND",
invalid: "INVALID",
expired: "EXPIRED",
suspended: "SUSPENDED",
};
function datum(wert: string | null | undefined): Date | null {
return wert ? new Date(wert) : null;
}
async function seedDemo() {
for (const kunde of kunden) {
await prisma.kunde.upsert({
where: { id: kunde.id },
update: {},
create: {
id: kunde.id,
name: kunde.name,
kundennummer: kunde.kundennummer,
ansprechpartner: kunde.ansprechpartner,
email: kunde.email,
telefon: kunde.telefon,
notizen: kunde.notizen,
standorte: {
create: kunde.standorte.map((standort) => ({
id: standort.id,
name: standort.name,
ort: standort.ort,
})),
},
},
});
}
for (const host of hosts) {
await prisma.host.upsert({
where: { id: host.id },
update: {},
create: {
id: host.id,
agentId: `demo-${host.id}`,
hostname: host.hostname,
fqdn: host.fqdn,
kundeId: host.kundeId,
standortId: host.standortId,
produkt: PRODUKT[host.produkt],
produktVersion: host.produktVersion,
kernel: host.kernel,
serverId: host.serverId,
clusterName: host.clusterName,
nodeName: host.nodeName,
ips: host.ips,
sockets: host.sockets,
cpuModell: host.cpuModell,
uptimeSekunden: host.uptimeSekunden === null ? null : BigInt(host.uptimeSekunden),
paketUpdates: host.paketUpdates,
agentVersion: host.agentVersion,
notizen: host.notizen,
subKey: host.subscription.key,
subStatus: STATUS[host.subscription.status],
subLevel: host.subscription.level,
subProduktname: host.subscription.produktname,
subLizenzierteSockets: host.subscription.lizenzierteSockets,
subRegdate: datum(host.subscription.regdate),
subNextduedate: datum(host.subscription.nextduedate),
subChecktime: datum(host.subscription.checktime),
letzterReportAm: datum(host.letzterReport),
tags: {
connectOrCreate: host.tags.map((tag) => ({ where: { name: tag }, create: { name: tag } })),
},
keyHistorie: host.subscription.key
? { create: [{ key: host.subscription.key }] }
: undefined,
serverIdHistorie: { create: [{ serverId: host.serverId }] },
},
});
}
// Rechnungen entstehen aus den Laufzeiten: gleiche Rechnungsnummer = eine Rechnung mit mehreren Positionen.
const proxmoxRechnungen = new Map<string, MockLaufzeit[]>();
const kundenrechnungen = new Map<string, MockLaufzeit[]>();
for (const laufzeit of laufzeiten) {
if (laufzeit.einkauf?.nummer) {
const bisher = proxmoxRechnungen.get(laufzeit.einkauf.nummer) ?? [];
proxmoxRechnungen.set(laufzeit.einkauf.nummer, [...bisher, laufzeit]);
}
if (laufzeit.verkauf) {
const schluessel = laufzeit.verkauf.nummer ?? `ohne-nummer-${laufzeit.id}`;
const bisher = kundenrechnungen.get(schluessel) ?? [];
kundenrechnungen.set(schluessel, [...bisher, laufzeit]);
}
}
const proxmoxPositionen = new Map<string, string>();
for (const [nummer, zugehoerig] of proxmoxRechnungen) {
const vorlage = zugehoerig[0].einkauf!;
const rechnung = await prisma.proxmoxRechnung.upsert({
where: { nummer },
update: {},
create: {
nummer,
datum: new Date(vorlage.datum!),
faelligAm: datum(vorlage.faelligAm),
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
ustSatz: vorlage.ustSatz,
rabatt: vorlage.rabatt ?? null,
zahlungsart: vorlage.zahlungsart ?? null,
bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm),
positionen: {
create: zugehoerig.map((l) => ({
id: `prp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
})),
},
},
});
for (const l of zugehoerig) proxmoxPositionen.set(l.id, `prp-${l.id}`);
console.log(`Proxmox-Rechnung ${rechnung.nummer} mit ${zugehoerig.length} Position(en)`);
}
const kundenPositionen = new Map<string, string>();
for (const [schluessel, zugehoerig] of kundenrechnungen) {
const vorlage = zugehoerig[0].verkauf!;
const host = hosts.find((h) => h.id === zugehoerig[0].hostId);
if (!host?.kundeId) continue;
const id = `kr-${schluessel}`;
await prisma.kundenrechnung.upsert({
where: { id },
update: {},
create: {
id,
kundeId: host.kundeId,
erstellt: vorlage.erstellt,
nummer: vorlage.nummer,
datum: datum(vorlage.datum),
nettoBetrag: vorlage.erstellt
? zugehoerig.reduce((summe, l) => summe + (l.verkauf?.nettoBetrag ?? 0), 0)
: null,
ustSatz: vorlage.ustSatz,
bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm),
positionen: {
create: zugehoerig.map((l) => ({
id: `krp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.verkauf?.nettoBetrag ?? 0,
})),
},
},
});
for (const l of zugehoerig) kundenPositionen.set(l.id, `krp-${l.id}`);
}
for (const laufzeit of laufzeiten) {
await prisma.laufzeit.upsert({
where: { id: laufzeit.id },
update: {},
create: {
id: laufzeit.id,
hostId: laufzeit.hostId,
key: laufzeit.key,
start: new Date(laufzeit.start),
ende: new Date(laufzeit.ende),
status: laufzeit.vorschlag ? "VORSCHLAG" : "BESTAETIGT",
proxmoxPositionId: proxmoxPositionen.get(laufzeit.id) ?? null,
kundenPositionId: kundenPositionen.get(laufzeit.id) ?? null,
},
});
}
for (const report of reports) {
await prisma.report.upsert({
where: { reportId: report.id },
update: {},
create: {
reportId: report.id,
hostId: report.hostId,
erhobenAm: new Date(report.zeitpunkt),
subStatus: STATUS[report.status],
subKey: report.key,
subNextduedate: datum(report.nextduedate),
agentVersion: report.agentVersion,
paketUpdates: report.paketUpdates,
rohdaten: { hinweis: "Demo-Report ohne Rohdaten" },
},
});
}
for (const alert of alerts) {
await prisma.alert.upsert({
where: { fingerprint: `${alert.typ}:${alert.hostId ?? alert.kundeId ?? alert.id}` },
update: {},
create: {
id: alert.id,
fingerprint: `${alert.typ}:${alert.hostId ?? alert.kundeId ?? alert.id}`,
regel: alert.typ,
schwere: alert.schwere === "gefahr" ? "GEFAHR" : alert.schwere === "warnung" ? "WARNUNG" : "INFO",
titel: alert.titel,
beschreibung: alert.beschreibung,
hostId: alert.hostId,
kundeId: alert.kundeId,
laufzeitId: alert.laufzeitId,
geoeffnetAm: new Date(alert.seit),
},
});
}
console.log(
`Demo-Daten: ${kunden.length} Kunden, ${hosts.length} Hosts, ${laufzeiten.length} Laufzeiten, ${alerts.length} Warnungen`,
);
}
async function main() {
await seedAdmin();
await seedWarnregeln();
if (process.env.SEED_DEMO === "1") {
await seedDemo();
} else {
console.log("Demo-Daten übersprungen (SEED_DEMO=1 setzen, um sie anzulegen).");
}
}
main()
.then(() => prisma.$disconnect())
.catch(async (fehler) => {
console.error(fehler);
await prisma.$disconnect();
process.exit(1);
});
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+24
View File
@@ -0,0 +1,24 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Prisma-Client als Singleton. Im Entwicklungsmodus lädt Next.js Module neu,
* ohne den Prozess zu beenden – ohne Singleton entstünden dabei immer neue Verbindungspools.
*/
declare global {
var proxledgerPrisma: PrismaClient | undefined;
}
function erzeugeClient(): PrismaClient {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example.");
}
return new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
}
export const prisma: PrismaClient = globalThis.proxledgerPrisma ?? erzeugeClient();
if (process.env.NODE_ENV !== "production") {
globalThis.proxledgerPrisma = prisma;
}
+101
View File
@@ -0,0 +1,101 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
describe.runIf(datenbankVerfuegbar)("Datenmodell", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("verteilt eine Proxmox-Sammelrechnung auf mehrere Laufzeiten", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Testkunde GmbH", kundennummer: "K-9001" },
});
const hostDaten = [
{ agentId: "test-1", hostname: "pve-test-01", key: "pve1c-aaaa111111" },
{ agentId: "test-2", hostname: "pve-test-02", key: "pve1c-bbbb222222" },
];
const rechnung = await testPrisma.proxmoxRechnung.create({
data: {
nummer: "PX-TEST-1",
datum: new Date("2026-09-01"),
faelligAm: new Date("2026-09-15"),
nettoBetrag: 230,
bezahlt: false,
positionen: {
create: hostDaten.map((h) => ({ bezeichnung: h.key, nettoBetrag: 115 })),
},
},
include: { positionen: true },
});
for (const [index, daten] of hostDaten.entries()) {
const host = await testPrisma.host.create({
data: {
agentId: daten.agentId,
hostname: daten.hostname,
produkt: "PVE",
kundeId: kunde.id,
subKey: daten.key,
subStatus: "ACTIVE",
subNextduedate: new Date("2027-09-01"),
},
});
await testPrisma.laufzeit.create({
data: {
hostId: host.id,
key: daten.key,
start: new Date("2026-09-01"),
ende: new Date("2027-08-31"),
status: "BESTAETIGT",
proxmoxPositionId: rechnung.positionen[index].id,
},
});
}
const laufzeiten = await testPrisma.laufzeit.findMany({
include: { proxmoxPosition: { include: { rechnung: true } } },
});
expect(laufzeiten).toHaveLength(2);
expect(laufzeiten.every((l) => l.proxmoxPosition?.rechnung.nummer === "PX-TEST-1")).toBe(true);
expect(
laufzeiten.reduce((summe, l) => summe + Number(l.proxmoxPosition?.nettoBetrag ?? 0), 0),
).toBe(230);
});
it("erlaubt Hosts ohne Kunde und setzt die Zuordnung beim Löschen des Kunden zurück", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Wegfallend GmbH", kundennummer: "K-9002" },
});
const host = await testPrisma.host.create({
data: { agentId: "test-3", hostname: "pve-test-03", produkt: "PVE", kundeId: kunde.id },
});
await testPrisma.kunde.delete({ where: { id: kunde.id } });
const danach = await testPrisma.host.findUnique({ where: { id: host.id } });
expect(danach?.kundeId).toBeNull();
});
it("verhindert zwei Laufzeiten mit gleichem Start je Host", async () => {
const host = await testPrisma.host.create({
data: { agentId: "test-4", hostname: "pve-test-04", produkt: "PBS" },
});
const daten = {
hostId: host.id,
key: "pbsc-1111111111",
start: new Date("2026-01-01"),
ende: new Date("2026-12-31"),
};
await testPrisma.laufzeit.create({ data: daten });
await expect(testPrisma.laufzeit.create({ data: daten })).rejects.toThrow();
});
})
+34
View File
@@ -0,0 +1,34 @@
import { execFileSync } from "node:child_process";
import { Client } from "pg";
import "dotenv/config";
/**
* Legt die Testdatenbank an (falls nötig) und spielt alle Migrationen ein.
* Ohne `TEST_DATABASE_URL` werden die datenbankgestützten Tests übersprungen.
*/
export default async function setup() {
const url = process.env.TEST_DATABASE_URL;
if (!url) {
console.warn("TEST_DATABASE_URL ist nicht gesetzt – Datenbanktests werden übersprungen.");
return;
}
const ziel = new URL(url);
const datenbank = ziel.pathname.replace(/^\//, "");
const verwaltung = new URL(url);
verwaltung.pathname = "/postgres";
const client = new Client({ connectionString: verwaltung.toString() });
await client.connect();
const vorhanden = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [datenbank]);
if (vorhanden.rowCount === 0) {
// Datenbanknamen lassen sich nicht parametrisieren; er stammt aus der eigenen Konfiguration.
await client.query(`CREATE DATABASE "${datenbank.replace(/"/g, "")}"`);
}
await client.end();
execFileSync("npx", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: url },
stdio: "inherit",
});
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import "dotenv/config";
const url = process.env.TEST_DATABASE_URL;
/** Datenbankgestützte Tests laufen nur, wenn eine Testdatenbank konfiguriert ist. */
export const datenbankVerfuegbar = Boolean(url);
export const testPrisma: PrismaClient = new PrismaClient({
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5432/proxledger_test" }),
});
/** Leert alle Tabellen; Reihenfolge spielt dank CASCADE keine Rolle. */
export async function datenbankLeeren() {
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
`;
if (tabellen.length === 0) return;
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
await testPrisma.$executeRawUnsafe(`TRUNCATE TABLE ${liste} RESTART IDENTITY CASCADE`);
}
+3
View File
@@ -8,5 +8,8 @@ export default defineConfig({
test: {
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
environment: "node",
globalSetup: ["tests/global-setup.ts"],
// Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen.
fileParallelism: false,
},
});