feat(db): Datenmodell, erste Migration und Seed (#003)
Prisma 7.10 mit Postgres-Adapter, 23 Modelle inklusive Rechnungen mit Positionen, Seed fuer Administrator, Warnregeln und Demo-Daten, Testdatenbank fuer Vitest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
@@ -51,3 +51,5 @@ Thumbs.db
|
||||
# Vercel / Sonstiges
|
||||
.vercel
|
||||
*.pem
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
@@ -4,3 +4,5 @@ coverage
|
||||
docs
|
||||
*.md
|
||||
package-lock.json
|
||||
src/generated
|
||||
prisma/migrations
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Generated
+2461
-14
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user