Compare commits
111 Commits
4e91d96b5b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dff0310a6 | |||
| ab971618d5 | |||
| 4407bbfbb8 | |||
| 365c7994d5 | |||
| 2c7a87ccd3 | |||
| 45f63d1c48 | |||
| 2d3ca28691 | |||
| 4201a90fd0 | |||
| 3fb1925a98 | |||
| 63ebf3e75f | |||
| 27a0fdbc45 | |||
| 6f293211a4 | |||
| 70e5190594 | |||
| 7d07d52774 | |||
| 75c1f9a7bb | |||
| 62010b05d5 | |||
| e401c11e40 | |||
| d206b360a6 | |||
| 096aa63c6f | |||
| 77602bb4ac | |||
| e763952a84 | |||
| 3823f8aa50 | |||
| 0671565433 | |||
| e145edaa90 | |||
| 3b4a680326 | |||
| 389b878dbd | |||
| 96feb6a663 | |||
| 49905aa97e | |||
| e2fdb069ac | |||
| 0cf3dd6a7b | |||
| 45fe270a38 | |||
| 73f271ae03 | |||
| 4385ae575d | |||
| 6b804cdc82 | |||
| df6eb9724d | |||
| 0c0cecdbbd | |||
| 35745ce3bb | |||
| dea2da0271 | |||
| 0a757d8e47 | |||
| 4e680a36e7 | |||
| a129781035 | |||
| 4ca91eb710 | |||
| 8aead8c2f6 | |||
| 301aafffd1 | |||
| 81f0e89058 | |||
| 1c46d7345c | |||
| 8fc050a282 | |||
| 0764bc6ddf | |||
| 8d113f4c6b | |||
| fd480113d0 | |||
| 95bf118fc2 | |||
| 075c095b8e | |||
| 3fa1dce2dc | |||
| b7d3654b72 | |||
| cdde7b4ab7 | |||
| cf4370c905 | |||
| 1de8fb9847 | |||
| fd55f3129f | |||
| 109f774d62 | |||
| 60dc98e265 | |||
| b78afce43c | |||
| 2879bd64d6 | |||
| aa2b5ce785 | |||
| 9d6bd68ddc | |||
| 2a3928d0e7 | |||
| ba29711ee7 | |||
| 888c75bb41 | |||
| 5adc71e52c | |||
| c93086059d | |||
| b47f33aaa5 | |||
| 018784cca6 | |||
| 2775e9d4dc | |||
| 0d58b79836 | |||
| eaf7d1eac3 | |||
| 9a84e2d3cb | |||
| 9fa1cbc591 | |||
| a0705b1a61 | |||
| 0e75e6c8e5 | |||
| a15772cb54 | |||
| fd55742c57 | |||
| 38b3b7da73 | |||
| eecc6cd73e | |||
| d7b42f64b1 | |||
| c3edb8ad2e | |||
| 09e87c951b | |||
| 468907c9c3 | |||
| e0fc26795e | |||
| 746706ef01 | |||
| 4f588015a4 | |||
| 55f257fffd | |||
| 2ab2bb7562 | |||
| 839bb40f5e | |||
| b397a974df | |||
| ad2b8ea5b6 | |||
| 89d528bb77 | |||
| e8919cfa81 | |||
| 80dd5cc157 | |||
| f33d157b9b | |||
| 8c65fecef0 | |||
| 866a285037 | |||
| c9d1ad7796 | |||
| f21eb20715 | |||
| 7a0f4461aa | |||
| d8ced5cb24 | |||
| b87053760e | |||
| 30103e6099 | |||
| bf068276b5 | |||
| 9256d9397b | |||
| 8c9e61cf17 | |||
| ef18381dd8 | |||
| e209e9bbca |
@@ -46,6 +46,15 @@ backups
|
|||||||
backend/uploads
|
backend/uploads
|
||||||
backend/backups
|
backend/backups
|
||||||
|
|
||||||
|
# Daten-Verzeichnis (Bind-Mounts zur Laufzeit, nicht im Build-Context)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Plesk-Test (nicht für Container)
|
||||||
|
plesktest/
|
||||||
|
|
||||||
|
# Backup-Klone des Repos
|
||||||
|
opencrm-backup-*/
|
||||||
|
|
||||||
# Prisma migrations (included, but not dev db)
|
# Prisma migrations (included, but not dev db)
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# OpenCRM – zentrale Konfiguration
|
||||||
|
# ==================================
|
||||||
|
# Kopiere diese Datei zu .env und passe die Werte an.
|
||||||
|
# Diese .env wird sowohl vom Backend (npm run dev) als auch von Docker
|
||||||
|
# Compose verwendet.
|
||||||
|
|
||||||
|
# ============== PORTS (extern erreichbar auf dem Host) ==============
|
||||||
|
OPENCRM_PORT=3010 # Backend + Frontend (alles unter einer URL)
|
||||||
|
ADMINER_PORT=8090 # Adminer (Datenbank-UI). 8081 ist häufig schon belegt.
|
||||||
|
DB_PORT=3306 # MariaDB extern (für lokale Tools/Dev). 0 = nicht freigeben.
|
||||||
|
|
||||||
|
# ============== DATEN-PFADE (Bind-Mounts) ==============
|
||||||
|
# Relativ zum Projektverzeichnis. Werden zur Laufzeit angelegt.
|
||||||
|
DATA_DIR=./data
|
||||||
|
DB_DATA_DIR=./data/db
|
||||||
|
UPLOADS_DIR=./data/uploads
|
||||||
|
FACTORY_DEFAULTS_DIR=./data/factory-defaults
|
||||||
|
BACKUPS_DIR=./data/backups
|
||||||
|
|
||||||
|
# ============== DATENBANK ==============
|
||||||
|
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
|
||||||
|
# angelegt (über MARIADB_USER/MARIADB_PASSWORD im docker-compose) – mit
|
||||||
|
# GRANT ALL PRIVILEGES auf ${DB_NAME}.*. Damit nutzt das Backend NICHT root.
|
||||||
|
# DB_ROOT_PASSWORD ist nur für Adminer / Notfall-Wartung.
|
||||||
|
DB_HOST=localhost # Im Container überschreibt docker-compose das auf "db"
|
||||||
|
DB_NAME=opencrm
|
||||||
|
DB_USER=opencrm
|
||||||
|
DB_PASSWORD=change-this-password
|
||||||
|
DB_ROOT_PASSWORD=change-this-root-password
|
||||||
|
|
||||||
|
# Connection-String wird aus den DB_*-Komponenten zusammengebaut (dotenv-expand).
|
||||||
|
# Manuell überschreiben nur wenn Sonderfälle (z.B. extra Query-Parameter).
|
||||||
|
# Hinweis: für lokales Dev mit MariaDB im Container nutze DB_HOST=localhost,
|
||||||
|
# weil docker-compose den DB-Port auf 127.0.0.1:DB_PORT mappt.
|
||||||
|
DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# ============== SECURITY ==============
|
||||||
|
# JWT-Secret: min. 32 Zeichen. Generieren: openssl rand -hex 64
|
||||||
|
JWT_SECRET=change-this-to-a-very-long-random-secret-please-rotate-before-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Encryption-Key für Portal-Credentials: GENAU 64 Hex-Zeichen.
|
||||||
|
# Generieren: openssl rand -hex 32
|
||||||
|
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
|
||||||
|
|
||||||
|
# Server
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
||||||
|
LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127.0.0.1
|
||||||
|
|
||||||
|
# CORS – nur in Production setzen, wenn Frontend auf separater Domain läuft.
|
||||||
|
# Beispiel: CORS_ORIGINS=https://crm.deine-domain.de
|
||||||
|
# CORS_ORIGINS=
|
||||||
|
|
||||||
|
# HTTPS-only-Header (HSTS + upgrade-insecure-requests) – NUR aktivieren, wenn
|
||||||
|
# wirklich ein TLS-Proxy (Caddy/Traefik/Nginx) vor OpenCRM steht. Sonst sperrt
|
||||||
|
# sich der Browser bei direktem http://ip:port-Zugriff selbst aus
|
||||||
|
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
|
||||||
|
HTTPS_ENABLED=false
|
||||||
|
|
||||||
|
# ============== ADMINER (DB-UI) ==============
|
||||||
|
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
|
||||||
|
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
|
||||||
|
# haeckel, hever, konya, lavender-light, lucas-sandery, mancave,
|
||||||
|
# mvt, nette, ng9, nicu, pappu687, paranoiq, pepa-linha, pokorny,
|
||||||
|
# price, rmsoft, rmsoft_blue, rmsoft_blue-dark, win98
|
||||||
|
# Empfehlung: dracula (dark) oder adminer-dark – beide modern.
|
||||||
|
ADMINER_DESIGN=dracula
|
||||||
|
|
||||||
|
# ============== SEED ==============
|
||||||
|
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
|
||||||
|
# + Stammdaten an) – nichts zu konfigurieren.
|
||||||
|
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
|
||||||
|
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
|
||||||
|
# wieder zurück.
|
||||||
|
RUN_SEED=false
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Root-Gitignore: gemeinsame Patterns für Repo-Root + nested Verzeichnisse
|
||||||
|
# (backend/, frontend/, docker/ haben zusätzlich eigene .gitignore-Files)
|
||||||
|
|
||||||
|
# Environment – echte Secrets blocken, .env.example weiter mittracken
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
tmp/
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Docker-Bind-Mounts: Inhalt nicht tracken, Verzeichnisstruktur via .gitkeep behalten
|
||||||
|
data/db/*
|
||||||
|
!data/db/.gitkeep
|
||||||
|
data/uploads/*
|
||||||
|
!data/uploads/.gitkeep
|
||||||
|
data/factory-defaults/*
|
||||||
|
!data/factory-defaults/.gitkeep
|
||||||
|
data/backups/*
|
||||||
|
!data/backups/.gitkeep
|
||||||
|
|
||||||
|
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
||||||
|
factory-exports/*
|
||||||
|
!factory-exports/.gitkeep
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
||||||
|
|
||||||
|
**Version: 1.1.0** ([Changelog](#changelog))
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
||||||
@@ -11,6 +13,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||||
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
||||||
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
||||||
|
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT` → `ACTIVE` (mit Vertragsbeginn),
|
||||||
|
Kündigungsbestätigung-Upload setzt `ACTIVE` → `CANCELLED` (mit Datum),
|
||||||
|
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
|
||||||
- **Verträge**:
|
- **Verträge**:
|
||||||
- Energie (Strom, Gas)
|
- Energie (Strom, Gas)
|
||||||
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
||||||
@@ -20,7 +25,14 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
||||||
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
||||||
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
||||||
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
|
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
|
||||||
|
Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||||
|
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
|
||||||
|
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
|
||||||
|
Sofort-E-Mail-Alerts und stündlichem Digest – siehe Einstellungen → Monitoring
|
||||||
|
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
|
||||||
|
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
|
||||||
|
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||||
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -35,32 +47,62 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
> - Express 4.x → `@types/express@^4.17.x`
|
> - Express 4.x → `@types/express@^4.17.x`
|
||||||
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
|
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
|
||||||
|
|
||||||
|
## Quick-Start mit Docker (empfohlen)
|
||||||
|
|
||||||
|
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd opencrm
|
||||||
|
cp .env.example .env # Werte anpassen, Secrets rotieren!
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
|
||||||
|
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
|
||||||
|
|
||||||
|
Alle persistenten Daten liegen in `./data/`:
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `./data/db/` | MariaDB-Datafiles |
|
||||||
|
| `./data/uploads/` | User-Uploads (PDFs, Bilder) |
|
||||||
|
| `./data/factory-defaults/` | Stammdaten-Kataloge |
|
||||||
|
| `./data/backups/` | DB-Backups (`npm run db:backup`) |
|
||||||
|
|
||||||
|
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
|
||||||
|
|
||||||
|
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
|
||||||
|
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
|
||||||
|
> der initiale Admin-User `admin@admin.com` / `admin`.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Node.js 18+ (empfohlen: 20+)
|
- Docker & Docker Compose v2
|
||||||
- Docker & Docker Compose
|
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
|
||||||
- npm
|
|
||||||
|
|
||||||
## Installation
|
## Installation für Entwicklung (ohne Container)
|
||||||
|
|
||||||
### 1. Repository klonen
|
### 1. Repository klonen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd opencrm
|
cd opencrm
|
||||||
|
cp .env.example .env # Konfiguration anpassen
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. MariaDB-Datenbank starten
|
### 2. MariaDB-Container starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d db
|
||||||
```
|
```
|
||||||
|
|
||||||
Dies startet einen MariaDB-Container mit:
|
Das startet nur die Datenbank (mit Daten in `./data/db/`).
|
||||||
- **Port:** 3306
|
Konfiguration kommt aus `./.env`:
|
||||||
- **Datenbank:** opencrm
|
|
||||||
- **Root-Passwort:** rootpassword
|
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
|
||||||
- **Benutzer:** opencrm / opencrm123
|
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
|
||||||
|
|
||||||
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
|
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
|
||||||
|
|
||||||
@@ -140,6 +182,39 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
|||||||
- **E-Mail:** admin@admin.com
|
- **E-Mail:** admin@admin.com
|
||||||
- **Passwort:** admin
|
- **Passwort:** admin
|
||||||
|
|
||||||
|
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
|
||||||
|
> ändern und Secrets rotieren – siehe [Production-Deployment](#production-deployment).
|
||||||
|
|
||||||
|
## Production-Deployment
|
||||||
|
|
||||||
|
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Pflicht-Rotation – per `openssl rand` neu generieren!
|
||||||
|
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
|
||||||
|
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
|
||||||
|
|
||||||
|
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
|
||||||
|
LISTEN_ADDR=127.0.0.1
|
||||||
|
|
||||||
|
# Bei separatem Frontend-Host: erlaubte Origins
|
||||||
|
CORS_ORIGINS=https://crm.deine-domain.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus:
|
||||||
|
|
||||||
|
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
|
||||||
|
die echte Client-IP gesetzt wird (nicht nur angefügt) – sonst Rate-Limit-Bypass möglich.
|
||||||
|
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
|
||||||
|
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
||||||
|
durchklicken.
|
||||||
|
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
||||||
|
hinterlegen, Test-Alert senden, Digest aktivieren.
|
||||||
|
- Vollständige Hardening-Story + restliche Trade-offs:
|
||||||
|
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||||
|
|
||||||
## Developer-Tools aktivieren
|
## Developer-Tools aktivieren
|
||||||
|
|
||||||
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
||||||
@@ -991,8 +1066,9 @@ Folgende Felder werden in Audit-Logs gefiltert:
|
|||||||
## Factory-Defaults: Stammdaten-Kataloge teilen
|
## Factory-Defaults: Stammdaten-Kataloge teilen
|
||||||
|
|
||||||
Das **Factory-Defaults**-System erlaubt den Export und Import von
|
Das **Factory-Defaults**-System erlaubt den Export und Import von
|
||||||
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen
|
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
|
||||||
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups:
|
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
|
||||||
|
zu Datenbank-Backups:
|
||||||
|
|
||||||
### Abgrenzung
|
### Abgrenzung
|
||||||
|
|
||||||
@@ -1000,64 +1076,117 @@ OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backup
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
|
||||||
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
|
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
|
||||||
|
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
|
||||||
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
|
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
|
||||||
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
|
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
|
||||||
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ |
|
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
|
||||||
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
|
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
|
||||||
|
|
||||||
> **Kurz:** Factory-Defaults = reine Kataloge, Backup = alles.
|
> **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
|
||||||
|
> Backup = die komplette Instanz.
|
||||||
|
|
||||||
### Export (Installation A → ZIP)
|
### Drei Wege, eine ZIP zu transportieren
|
||||||
|
|
||||||
|
Es gibt drei Pfade, je nachdem wo die ZIP gerade liegen soll:
|
||||||
|
|
||||||
|
| Wo | Pfad | Wann |
|
||||||
|
|---|---|---|
|
||||||
|
| **Laufende DB einer Instanz** | UI-Upload oder `./factory-import.sh` | Bestehende Live-Instanz updaten |
|
||||||
|
| **Drop-Box im Repo** (`factory-exports/`) | `./factory-export.sh` legt ab, `./factory-import.sh` liest | Transfer zwischen dev und prod via `scp` |
|
||||||
|
| **Werkseinstellung im Image** (`backend/factory-defaults/`) | `./factory-import.sh --save-as-builtin` oder manuell entpacken | Neue VMs sollen die Defaults beim allerersten Start mitbringen |
|
||||||
|
|
||||||
|
Alle drei sind unabhängig, **alle drei zusammen** decken den typischen Workflow ab.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
**Variante A – UI:**
|
||||||
1. **Einstellungen** → **Factory-Defaults** öffnen
|
1. **Einstellungen** → **Factory-Defaults** öffnen
|
||||||
2. Übersicht prüfen (Anzahl pro Kategorie)
|
2. Button **„Factory-Defaults exportieren"** klicken
|
||||||
3. Button **„Factory-Defaults exportieren"** klicken
|
3. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
|
||||||
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
|
|
||||||
|
**Variante B – CLI (für scp-Transfers):**
|
||||||
|
```bash
|
||||||
|
./factory-export.sh # → factory-exports/factory-defaults-…zip
|
||||||
|
OPENCRM_URL=https://crm.prod.example.de \
|
||||||
|
OPENCRM_EMAIL=admin@example.de ./factory-export.sh # gegen Prod-Instanz
|
||||||
|
```
|
||||||
|
Ohne `OPENCRM_PASSWORD` wird das Passwort interaktiv abgefragt. Der Zielordner
|
||||||
|
`factory-exports/` ist gitignored – die ZIPs landen also nicht ins Repo.
|
||||||
|
|
||||||
**ZIP-Struktur:**
|
**ZIP-Struktur:**
|
||||||
```
|
```
|
||||||
factory-defaults-2026-04-23.zip
|
factory-defaults-2026-05-07-1949.zip
|
||||||
├── manifest.json # Version + Datum + Counts
|
├── manifest.json # Version + Datum + Counts
|
||||||
├── providers/
|
├── providers/providers.json
|
||||||
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
|
|
||||||
├── contract-meta/
|
├── contract-meta/
|
||||||
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung)
|
│ ├── cancellation-periods.json
|
||||||
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung)
|
│ ├── contract-durations.json
|
||||||
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...)
|
│ └── contract-categories.json
|
||||||
└── pdf-templates/
|
├── pdf-templates/
|
||||||
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen
|
│ ├── pdf-templates.json
|
||||||
└── *.pdf # Die eigentlichen PDF-Dateien
|
│ └── *.pdf # Die eigentlichen PDF-Dateien
|
||||||
|
└── app-settings/
|
||||||
|
└── app-settings.json # HTML-Templates (Whitelist-only)
|
||||||
```
|
```
|
||||||
|
|
||||||
Die ZIP kann an andere Installationen weitergegeben werden
|
### Import
|
||||||
(Partner, Test-System, neue Installation).
|
|
||||||
|
|
||||||
### Import (ZIP → Installation B)
|
**Variante A – UI:**
|
||||||
|
1. **Einstellungen** → **Factory-Defaults**
|
||||||
|
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
|
||||||
|
3. Erfolgs-Box zeigt Counts pro Kategorie
|
||||||
|
|
||||||
1. ZIP herunterladen bzw. erhalten
|
**Variante B – CLI:**
|
||||||
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
|
|
||||||
3. Im Backend-Verzeichnis ausführen:
|
|
||||||
```bash
|
```bash
|
||||||
npm run seed:defaults
|
./factory-import.sh # nimmt jüngste ZIP aus factory-exports/
|
||||||
|
./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
|
||||||
|
./factory-import.sh --save-as-builtin # zusätzlich ins Image-Default
|
||||||
|
./factory-import.sh --save-as-builtin ./foo.zip # entpacken (siehe unten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfigurierbar per ENV: `OPENCRM_URL`, `OPENCRM_EMAIL`, `OPENCRM_PASSWORD`.
|
||||||
|
|
||||||
|
**Variante C – Container-Bare-Metal (für Migration / mehrere ZIPs zusammenführen):**
|
||||||
|
```bash
|
||||||
|
# Inhalt der ZIP nach backend/factory-defaults/ entpacken (Unterordner beibehalten)
|
||||||
|
cd backend && npm run seed:defaults
|
||||||
```
|
```
|
||||||
|
|
||||||
**Beispiel-Output:**
|
**Beispiel-Output:**
|
||||||
```
|
```
|
||||||
📦 Factory-Defaults werden eingespielt...
|
✓ Anbieter: 10
|
||||||
|
✓ Tarife: 4
|
||||||
✓ Anbieter: 7, Tarife: 12
|
✓ Kündigungsfristen: 18
|
||||||
✓ Kündigungsfristen: 5
|
✓ Laufzeiten: 18
|
||||||
✓ Laufzeiten: 4
|
|
||||||
✓ Vertragskategorien: 8
|
✓ Vertragskategorien: 8
|
||||||
✓ PDF-Vorlagen: 3
|
✓ PDF-Vorlagen: 2
|
||||||
|
✓ HTML-Templates: 2
|
||||||
✅ Factory-Defaults erfolgreich eingespielt.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mehrere ZIPs kombinieren
|
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
|
||||||
|
|
||||||
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen –
|
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
|
||||||
JSON-Dateien werden automatisch gemerged:
|
DB-Import** zusätzlich in `backend/factory-defaults/`. Beim nächsten
|
||||||
|
`docker-compose up --build` landen die Defaults im Image. Frisch hochgezogene
|
||||||
|
VMs bringen sie dann beim ersten Start automatisch mit (Auto-Seed-Pfad im
|
||||||
|
Container-Entrypoint).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# typischer Sync prod → dev → Image-Default
|
||||||
|
ssh prod './factory-export.sh'
|
||||||
|
scp prod:opencrm/factory-exports/factory-defaults-*.zip factory-exports/
|
||||||
|
./factory-import.sh --save-as-builtin
|
||||||
|
docker-compose up -d --build # neuer Build, neue VMs starten direkt mit Defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Inhalt von `backend/factory-defaults/` wird beim `--save-as-builtin` vorher
|
||||||
|
geleert (außer `README.md` und `.gitkeep`), damit nichts Veraltetes liegen
|
||||||
|
bleibt.
|
||||||
|
|
||||||
|
### Mehrere ZIPs kombinieren (CLI-only, Variante C)
|
||||||
|
|
||||||
|
`backend/factory-defaults/` darf mehrere `*.json` pro Unterordner haben –
|
||||||
|
`npm run seed:defaults` merged sie automatisch:
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/factory-defaults/
|
backend/factory-defaults/
|
||||||
@@ -1067,44 +1196,121 @@ backend/factory-defaults/
|
|||||||
eigene.json # 5 eigene Anbieter
|
eigene.json # 5 eigene Anbieter
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per
|
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
|
||||||
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch.
|
nimmt nur eine ZIP entgegen – für Merges nutze `npm run seed:defaults`.
|
||||||
|
|
||||||
### Idempotenz
|
### Idempotenz
|
||||||
|
|
||||||
Das Script nutzt ausschließlich Prisma `upsert`:
|
Alle Pfade nutzen Prisma `upsert`:
|
||||||
- **Neue Einträge** werden angelegt
|
- **Neue Einträge** werden angelegt
|
||||||
- **Bestehende Einträge** (per unique Key: `name`, `code`) werden aktualisiert
|
- **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
|
||||||
- Nichts wird gelöscht
|
- Nichts wird gelöscht
|
||||||
|
|
||||||
Du kannst `npm run seed:defaults` also beliebig oft ausführen, ohne Datenverlust
|
Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
|
||||||
oder Duplikate.
|
oder Duplikate.
|
||||||
|
|
||||||
### PDF-Dateien beim Import
|
### PDF-Dateien
|
||||||
|
|
||||||
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach
|
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
|
||||||
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt.
|
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
|
||||||
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue
|
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
|
||||||
ersetzt.
|
die neue ersetzt.
|
||||||
|
|
||||||
|
### AppSettings-Whitelist
|
||||||
|
|
||||||
|
Beim Import werden nur die Keys mit AppSetting-Schreibzugriff gewährt, die auch
|
||||||
|
exportiert werden – aktuell:
|
||||||
|
|
||||||
|
- `privacyPolicyHtml`
|
||||||
|
- `imprintHtml`
|
||||||
|
- `authorizationTemplateHtml`
|
||||||
|
- `websitePrivacyPolicyHtml`
|
||||||
|
|
||||||
|
Andere Keys (SMTP, JWT, etc.) werden mit einer Warnung ignoriert. Whitelist ist
|
||||||
|
in [`backend/src/services/factoryDefaults.service.ts`](backend/src/services/factoryDefaults.service.ts)
|
||||||
|
zentral gepflegt.
|
||||||
|
|
||||||
|
### Auto-Seed beim Erst-Deploy
|
||||||
|
|
||||||
|
Bei einer **frischen** Installation (leere DB) spielt der Container-Entrypoint
|
||||||
|
nach dem Prisma-Seed automatisch das Built-in-Verzeichnis ein:
|
||||||
|
|
||||||
|
```
|
||||||
|
[entrypoint] DB ist leer (User-Count=0) – Auto-Seed wird ausgeführt
|
||||||
|
[entrypoint] Spiele eingebaute Factory-Defaults ein…
|
||||||
|
✓ Anbieter: 10, Tarife: 4
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei bestehenden Installs passiert das **nicht** – nur frische DBs.
|
||||||
|
|
||||||
### Berechtigungen
|
### Berechtigungen
|
||||||
|
|
||||||
| Aktion | Berechtigung |
|
| Aktion | Berechtigung |
|
||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| Factory-Defaults Vorschau | `settings:read` |
|
| Factory-Defaults Vorschau | `settings:read` |
|
||||||
| Factory-Defaults Export | `settings:update` |
|
| Factory-Defaults Export (UI/CLI) | `settings:update` |
|
||||||
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
| Factory-Defaults Import (UI/CLI) | `settings:update` |
|
||||||
|
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
|
||||||
|
|
||||||
### Typischer Einsatzzweck
|
### Typische Einsatzzwecke
|
||||||
|
|
||||||
- **Neue Installation aufsetzen**: Eine Kollegen-ZIP importieren und sofort mit
|
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
|
||||||
gepflegtem Anbieter- und Vorlagenkatalog loslegen
|
(oder per `--save-as-builtin`), dann `docker-compose up --build` – die
|
||||||
|
Werkseinstellungen sind beim ersten Start automatisch drin.
|
||||||
|
- **Prod-Stand zurück nach dev synchronisieren**: `./factory-export.sh` auf prod,
|
||||||
|
`scp` ins dev, `./factory-import.sh --save-as-builtin` lokal – damit ist
|
||||||
|
sowohl die dev-DB aktuell als auch der nächste Image-Build.
|
||||||
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
|
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
|
||||||
(die anderen Ordner einfach aus der ZIP entfernen vor dem Entpacken)
|
(andere Ordner aus der ZIP entfernen vor dem Entpacken).
|
||||||
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
|
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
|
||||||
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
||||||
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
|
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 1.1.0 (2026-05-01)
|
||||||
|
|
||||||
|
**Production-readiness** – die Version, die wirklich öffentlich gehen darf.
|
||||||
|
|
||||||
|
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
|
||||||
|
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
||||||
|
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
|
||||||
|
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
|
||||||
|
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
|
||||||
|
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
|
||||||
|
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
|
||||||
|
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
|
||||||
|
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
|
||||||
|
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
|
||||||
|
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
|
||||||
|
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
|
||||||
|
- 🔄 **Auto-Vertragsstatus**:
|
||||||
|
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
|
||||||
|
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
|
||||||
|
(mit Datums-Modal beim Upload)
|
||||||
|
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
|
||||||
|
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
|
||||||
|
Login transparent auf Cost 12 geupgradet.
|
||||||
|
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
|
||||||
|
über `tokenInvalidatedAt`.
|
||||||
|
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
|
||||||
|
path-to-regexp, undici, minimatch).
|
||||||
|
|
||||||
|
### 1.0.0
|
||||||
|
|
||||||
|
Erste Release-Version.
|
||||||
|
|
||||||
|
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
|
||||||
|
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
|
||||||
|
- Vertrags-Cockpit mit Rechnungsprüfung
|
||||||
|
- E-Mail-Client mit Anhang-Verwaltung
|
||||||
|
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
|
||||||
|
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
|
||||||
|
- Factory-Defaults für Stammdaten-Kataloge
|
||||||
|
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
|
||||||
|
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# Database - Root für Migrationen, opencrm-User für Runtime
|
|
||||||
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
|
||||||
JWT_EXPIRES_IN="7d"
|
|
||||||
|
|
||||||
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
|
||||||
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
NODE_ENV=development
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# Backend nutzt seit v1.1 die zentrale Root-.env im Projektverzeichnis.
|
||||||
|
# → siehe ../.env.example für alle Variablen
|
||||||
|
#
|
||||||
|
# Diese Datei bleibt als Legacy-Fallback: wenn /.env nicht existiert,
|
||||||
|
# liest das Backend backend/.env (z.B. für isolierte Backend-Tests).
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -4,10 +4,11 @@ node_modules/
|
|||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Environment
|
# Environment – echte Secrets blocken, .env.example weiter mittracken
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Database Backups (can be large, keep folder structure)
|
# Database Backups (can be large, keep folder structure)
|
||||||
prisma/backups/*
|
prisma/backups/*
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Multi-Stage Build: Frontend bauen, dann Backend bauen, dann schlankes Runtime-Image
|
||||||
|
# ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Alle Stages auf node:20-slim (Debian-basiert) – dann passt die Prisma-Query-
|
||||||
|
# Engine (glibc + openssl) zur Runtime.
|
||||||
|
|
||||||
|
# ============== STAGE 1: Frontend bauen ==============
|
||||||
|
FROM node:20-slim AS frontend-builder
|
||||||
|
WORKDIR /build/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund --prefer-offline
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
# Output: /build/frontend/dist/
|
||||||
|
|
||||||
|
# ============== STAGE 2: Backend bauen (TS → JS) ==============
|
||||||
|
FROM node:20-slim AS backend-builder
|
||||||
|
WORKDIR /build/backend
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund --prefer-offline
|
||||||
|
COPY backend/prisma ./prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
COPY backend/tsconfig.json ./
|
||||||
|
COPY backend/src ./src
|
||||||
|
RUN npx tsc
|
||||||
|
# Output: /build/backend/dist/
|
||||||
|
|
||||||
|
# ============== STAGE 3: Runtime ==============
|
||||||
|
# Debian-slim statt Alpine: Prisma-Engines erwarten libssl 1.1, das in Alpine 3.19+
|
||||||
|
# nicht mehr verfügbar ist. Slim hat openssl 3 ABI-kompatibel + native binaries.
|
||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# OpenSSL für Prisma-Query-Engine + wget für Healthcheck
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Nur Production-Dependencies + Prisma-Client
|
||||||
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --no-audit --no-fund --prefer-offline && npm cache clean --force
|
||||||
|
|
||||||
|
# Build-Artefakte aus Stage 2
|
||||||
|
COPY --from=backend-builder /build/backend/dist ./dist
|
||||||
|
COPY --from=backend-builder /build/backend/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=backend-builder /build/backend/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY backend/prisma ./prisma
|
||||||
|
|
||||||
|
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
|
||||||
|
COPY --from=frontend-builder /build/frontend/dist ./public
|
||||||
|
|
||||||
|
# Eingebaute Werkseinstellungen ins Image: bei Erstinstallation (leerer DB) zieht
|
||||||
|
# der Entrypoint sie via tsx scripts/seed-factory-defaults.ts ein. Liegt in einem
|
||||||
|
# eigenen Pfad – `factory-defaults/` selbst kann über Bind-Mount überlagert werden.
|
||||||
|
COPY backend/factory-defaults /app/factory-defaults-builtin
|
||||||
|
COPY backend/scripts /app/scripts
|
||||||
|
|
||||||
|
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
|
||||||
|
RUN mkdir -p uploads factory-defaults prisma/backups
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider "http://localhost:${PORT:-3001}/api/health" || exit 1
|
||||||
|
|
||||||
|
# Beim Start: prisma db push (idempotent), dann node
|
||||||
|
COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
Executable
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container-Start:
|
||||||
|
# 1) Auf DB warten
|
||||||
|
# 2) Auto-Baseline für bestehende DBs (db-push-Ära ohne _prisma_migrations)
|
||||||
|
# 3) `prisma migrate deploy` (idempotent, datenerhaltend)
|
||||||
|
# 4) Auto-Seed bei leerer User-Tabelle (oder RUN_SEED=true)
|
||||||
|
# Neue Schema-Änderung anlegen (lokal, im Dev): npm run schema:sync
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
|
||||||
|
# Wichtig: encodeURIComponent für DB_USER + DB_PASSWORD, damit Sonderzeichen
|
||||||
|
# wie $, !, #, @, :, / etc. nicht die URL-Authority-Syntax brechen.
|
||||||
|
# Wir nutzen node-eval (ist eh installiert), kein extra-Tool wie jq nötig.
|
||||||
|
if [ -z "$DATABASE_URL" ] && [ -n "$DB_USER" ] && [ -n "$DB_PASSWORD" ] && [ -n "$DB_NAME" ]; then
|
||||||
|
DATABASE_URL=$(node -e "
|
||||||
|
const u = encodeURIComponent(process.env.DB_USER);
|
||||||
|
const p = encodeURIComponent(process.env.DB_PASSWORD);
|
||||||
|
const h = process.env.DB_HOST || 'db';
|
||||||
|
const port = process.env.DB_PORT || '3306';
|
||||||
|
const n = process.env.DB_NAME;
|
||||||
|
process.stdout.write(\`mysql://\${u}:\${p}@\${h}:\${port}/\${n}\`);
|
||||||
|
")
|
||||||
|
export DATABASE_URL
|
||||||
|
echo "[entrypoint] DATABASE_URL aus DB_*-Komponenten gebaut (host=${DB_HOST:-db})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] Warte auf Datenbank…"
|
||||||
|
# Erst auf DB-Verfügbarkeit warten via einfachem Connect-Check.
|
||||||
|
# Wir nutzen Prisma's interne Engine, kein extra mysql-client nötig.
|
||||||
|
TRIES=30
|
||||||
|
until node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.\$queryRaw\`SELECT 1\`
|
||||||
|
.then(() => p.\$disconnect().then(() => process.exit(0)))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
" 2>/dev/null; do
|
||||||
|
TRIES=$((TRIES - 1))
|
||||||
|
if [ "$TRIES" -le 0 ]; then
|
||||||
|
echo "[entrypoint] DB nicht erreichbar – Abbruch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[entrypoint] DB noch nicht bereit – retry in 2s ($TRIES Versuche übrig)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "[entrypoint] DB erreichbar"
|
||||||
|
|
||||||
|
# Auto-Baseline: Wenn die DB Anwendungs-Tabellen enthält (z.B. User), aber noch
|
||||||
|
# keine _prisma_migrations-Tabelle, dann ist es eine "alte" DB, die früher mit
|
||||||
|
# `prisma db push` synced wurde. Wir markieren 0_init als bereits angewendet,
|
||||||
|
# damit `migrate deploy` nicht versucht, alle Tabellen nochmal anzulegen.
|
||||||
|
NEEDS_BASELINE=$(node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const dbName = process.env.DB_NAME;
|
||||||
|
const tables = await p.\$queryRawUnsafe(
|
||||||
|
\`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?\`,
|
||||||
|
dbName
|
||||||
|
);
|
||||||
|
const names = tables.map(t => t.TABLE_NAME);
|
||||||
|
const hasMigrations = names.includes('_prisma_migrations');
|
||||||
|
const hasUserTable = names.includes('User');
|
||||||
|
// Existing DB (User da) ohne Migrations-Tracking => Baseline nötig
|
||||||
|
if (hasUserTable && !hasMigrations) process.stdout.write('yes');
|
||||||
|
else process.stdout.write('no');
|
||||||
|
} catch (e) {
|
||||||
|
process.stdout.write('no');
|
||||||
|
} finally {
|
||||||
|
await p.\$disconnect();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$NEEDS_BASELINE" = "yes" ]; then
|
||||||
|
echo "[entrypoint] Bestehende DB ohne Migrations-Tracking erkannt – markiere 0_init als angewendet (Baseline)"
|
||||||
|
npx prisma migrate resolve --applied 0_init || echo "[entrypoint] Baseline fehlgeschlagen – fahre trotzdem fort"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Migrations anwenden (idempotent: bereits angewendete werden übersprungen).
|
||||||
|
# Im Gegensatz zu `db push` löscht `migrate deploy` keine Daten — Schema-
|
||||||
|
# Änderungen werden über versionierte Migrations-Files unter prisma/migrations/
|
||||||
|
# eingespielt. Neue Migration anlegen mit: npm run schema:sync (lokal, dev).
|
||||||
|
echo "[entrypoint] Wende Migrations an…"
|
||||||
|
if ! npx prisma migrate deploy; then
|
||||||
|
echo "[entrypoint] migrate deploy fehlgeschlagen – Abbruch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[entrypoint] DB-Schema aktuell"
|
||||||
|
|
||||||
|
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
||||||
|
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||||
|
USER_COUNT=$(node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.user.count()
|
||||||
|
.then((n) => { process.stdout.write(String(n)); process.exit(0); })
|
||||||
|
.catch(() => { process.stdout.write('-1'); process.exit(0); });
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
RAN_SEED=false
|
||||||
|
if [ "${RUN_SEED:-false}" = "true" ]; then
|
||||||
|
echo "[entrypoint] RUN_SEED=true – seede DB (Force)"
|
||||||
|
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Seed fehlgeschlagen oder schon gelaufen – ignoriert"; fi
|
||||||
|
elif [ "$USER_COUNT" = "0" ]; then
|
||||||
|
echo "[entrypoint] DB ist leer (User-Count=0) – Auto-Seed wird ausgeführt"
|
||||||
|
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Auto-Seed fehlgeschlagen – ignoriert"; fi
|
||||||
|
else
|
||||||
|
echo "[entrypoint] DB enthält $USER_COUNT User – kein Seed nötig"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Eingebaute Factory-Defaults nach Erstinstallation einspielen.
|
||||||
|
# Das ist die Werkseinstellung für neue VMs: PDF-Vorlagen, Anbieter, Tarife,
|
||||||
|
# HTML-Templates – alles aus /app/factory-defaults-builtin/. Erfolgt nur wenn
|
||||||
|
# der Auto-Seed gerade lief (= frische DB), sonst werden Updates auf
|
||||||
|
# bestehenden Installationen nicht ungewollt überschrieben.
|
||||||
|
if [ "$RAN_SEED" = "true" ] && [ -d /app/factory-defaults-builtin ] \
|
||||||
|
&& [ -n "$(ls -A /app/factory-defaults-builtin 2>/dev/null | grep -v -E '^(README\.md|\.gitkeep)$')" ]; then
|
||||||
|
echo "[entrypoint] Spiele eingebaute Factory-Defaults ein…"
|
||||||
|
FACTORY_DEFAULTS_DIR=/app/factory-defaults-builtin npx tsx scripts/seed-factory-defaults.ts \
|
||||||
|
|| echo "[entrypoint] Factory-Defaults-Seed fehlgeschlagen – ignoriert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] Starte Backend…"
|
||||||
|
exec "$@"
|
||||||
@@ -18,15 +18,21 @@ backend/factory-defaults/
|
|||||||
│ ├── cancellation-periods.json # Kündigungsfristen
|
│ ├── cancellation-periods.json # Kündigungsfristen
|
||||||
│ ├── contract-durations.json # Vertragslaufzeiten
|
│ ├── contract-durations.json # Vertragslaufzeiten
|
||||||
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
||||||
└── pdf-templates/
|
├── pdf-templates/
|
||||||
├── pdf-templates.json # Metadaten + Feldzuordnungen
|
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
|
||||||
└── *.pdf # PDF-Vorlagen-Dateien
|
│ └── *.pdf # PDF-Vorlagen-Dateien
|
||||||
|
└── app-settings/
|
||||||
|
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
|
||||||
|
# Vollmacht / Website-Datenschutz
|
||||||
```
|
```
|
||||||
|
|
||||||
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
||||||
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten
|
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
|
||||||
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
||||||
|
|
||||||
|
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
|
||||||
|
Standardpflichten) – andere Keys werden beim Import ignoriert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Export (aus einer bestehenden Installation)
|
## Export (aus einer bestehenden Installation)
|
||||||
@@ -46,7 +52,8 @@ factory-defaults-2026-04-23.zip
|
|||||||
├── contract-meta/contract-durations.json
|
├── contract-meta/contract-durations.json
|
||||||
├── contract-meta/contract-categories.json
|
├── contract-meta/contract-categories.json
|
||||||
├── pdf-templates/pdf-templates.json
|
├── pdf-templates/pdf-templates.json
|
||||||
└── pdf-templates/*.pdf
|
├── pdf-templates/*.pdf
|
||||||
|
└── app-settings/app-settings.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
||||||
@@ -56,7 +63,15 @@ neue Installationen oder Partner-Setups.
|
|||||||
|
|
||||||
## Import (in eine andere Installation)
|
## Import (in eine andere Installation)
|
||||||
|
|
||||||
### Schritt-für-Schritt
|
### Variante A: Über die UI (empfohlen)
|
||||||
|
|
||||||
|
1. Im Ziel-CRM als Admin einloggen
|
||||||
|
2. **Einstellungen → Factory-Defaults**
|
||||||
|
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
|
||||||
|
4. Die exportierte ZIP wählen – der Import läuft direkt
|
||||||
|
5. Erfolgsmeldung zeigt Counts pro Kategorie an
|
||||||
|
|
||||||
|
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
|
||||||
|
|
||||||
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
||||||
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
||||||
@@ -234,6 +249,24 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|
|||||||
**Unique Key:** `name`
|
**Unique Key:** `name`
|
||||||
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
||||||
|
|
||||||
|
### `app-settings/app-settings.json`
|
||||||
|
|
||||||
|
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv – andere Keys
|
||||||
|
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
|
||||||
|
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
|
||||||
|
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
|
||||||
|
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unique Key:** `key`
|
||||||
|
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
|
||||||
|
`websitePrivacyPolicyHtml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Berechtigungen
|
## Berechtigungen
|
||||||
@@ -242,6 +275,7 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|
|||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| Factory-Defaults Vorschau | `settings:read` |
|
| Factory-Defaults Vorschau | `settings:read` |
|
||||||
| Factory-Defaults Export (UI) | `settings:update` |
|
| Factory-Defaults Export (UI) | `settings:update` |
|
||||||
|
| Factory-Defaults Import (UI) | `settings:update` |
|
||||||
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+166
-83
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"dotenv-expand": "^13.0.0",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
"express-rate-limit": "^8.4.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"undici": "^6.23.0"
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -42,7 +44,6 @@
|
|||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,7 +54,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -69,7 +69,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -85,7 +84,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -101,7 +99,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -117,7 +114,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -133,7 +129,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -149,7 +144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -165,7 +159,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -181,7 +174,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -197,7 +189,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -213,7 +204,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -229,7 +219,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -245,7 +234,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -261,7 +249,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -277,7 +264,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -293,7 +279,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -309,7 +294,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -325,7 +309,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -341,7 +324,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -357,7 +339,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -373,7 +354,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -389,7 +369,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -405,7 +384,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -421,7 +399,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -437,7 +414,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -453,7 +429,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -511,7 +486,8 @@
|
|||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
@@ -986,6 +962,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -1069,9 +1046,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1438,33 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv-expand": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv-expand/node_modules/dotenv": {
|
||||||
|
"version": "17.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1554,7 +1559,6 @@
|
|||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
@@ -1781,7 +1785,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1838,7 +1841,6 @@
|
|||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -2003,19 +2005,31 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.2.8",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
|
||||||
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
|
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.8",
|
"@zone-eu/mailsplit": "5.4.9",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.3.7",
|
"libmime": "5.3.8",
|
||||||
"libqp": "2.1.1",
|
"libqp": "2.1.1",
|
||||||
"nodemailer": "7.0.13",
|
"nodemailer": "8.0.7",
|
||||||
"pino": "10.3.0",
|
"pino": "10.3.1",
|
||||||
"socks": "2.8.7"
|
"socks": "2.8.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
|
||||||
|
"version": "5.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
|
||||||
|
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
|
||||||
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
|
"dependencies": {
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.3.8",
|
||||||
|
"libqp": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow/node_modules/iconv-lite": {
|
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||||
@@ -2033,6 +2047,27 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/imapflow/node_modules/libmime": {
|
||||||
|
"version": "5.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||||
|
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.2",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/imapflow/node_modules/nodemailer": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -2225,9 +2260,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
@@ -2270,18 +2306,19 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.9.3",
|
"version": "3.9.8",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
||||||
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.8",
|
"@zone-eu/mailsplit": "5.4.8",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libmime": "5.3.7",
|
"libmime": "5.3.8",
|
||||||
"linkify-it": "5.0.0",
|
"linkify-it": "5.0.0",
|
||||||
"nodemailer": "7.0.13",
|
"nodemailer": "8.0.5",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.261.0"
|
"tlds": "1.261.0"
|
||||||
}
|
}
|
||||||
@@ -2301,6 +2338,27 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mailparser/node_modules/libmime": {
|
||||||
|
"version": "5.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||||
|
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.2",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mailparser/node_modules/nodemailer": {
|
||||||
|
"version": "8.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||||
|
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2364,11 +2422,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -2483,6 +2542,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -2552,9 +2612,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pdf-lib": {
|
"node_modules/pdf-lib": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
@@ -2601,9 +2662,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinojs/redact": "^0.4.0",
|
"@pinojs/redact": "^0.4.0",
|
||||||
"atomic-sleep": "^1.0.0",
|
"atomic-sleep": "^1.0.0",
|
||||||
@@ -2625,6 +2687,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"split2": "^4.0.0"
|
"split2": "^4.0.0"
|
||||||
}
|
}
|
||||||
@@ -2632,7 +2695,8 @@
|
|||||||
"node_modules/pino-std-serializers": {
|
"node_modules/pino-std-serializers": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/png-js": {
|
"node_modules/png-js": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2684,7 +2748,8 @@
|
|||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/fastify"
|
"url": "https://opencollective.com/fastify"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
@@ -2707,9 +2772,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -2723,7 +2789,8 @@
|
|||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -2775,9 +2842,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -2789,6 +2857,7 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.13.0"
|
"node": ">= 12.13.0"
|
||||||
}
|
}
|
||||||
@@ -2797,7 +2866,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
"dev": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
@@ -2830,6 +2898,7 @@
|
|||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -3010,17 +3079,19 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0",
|
"node": ">= 6.0.0",
|
||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.7",
|
"version": "2.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
|
||||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.0.1",
|
"ip-address": "^10.1.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3028,10 +3099,20 @@
|
|||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socks/node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"atomic-sleep": "^1.0.0"
|
"atomic-sleep": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -3040,6 +3121,7 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
@@ -3193,6 +3275,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"real-require": "^0.2.0"
|
"real-require": "^0.2.0"
|
||||||
},
|
},
|
||||||
@@ -3230,7 +3313,6 @@
|
|||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -3281,9 +3363,10 @@
|
|||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.23.0",
|
"version": "6.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "OpenCRM Backend API",
|
"description": "OpenCRM Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
|
"schema:sync": "prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:backup": "tsx prisma/backup-data.ts",
|
"db:backup": "tsx prisma/backup-data.ts",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"dotenv-expand": "^13.0.0",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
"express-rate-limit": "^8.4.0",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"undici": "^6.23.0"
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -53,7 +56,6 @@
|
|||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,989 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `PdfTemplate` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`providerName` VARCHAR(191) NULL,
|
||||||
|
`templatePath` VARCHAR(191) NOT NULL,
|
||||||
|
`originalName` VARCHAR(191) NOT NULL,
|
||||||
|
`fieldMapping` LONGTEXT NOT NULL,
|
||||||
|
`phoneFieldPrefix` VARCHAR(191) NULL,
|
||||||
|
`maxPhoneFields` INTEGER NULL DEFAULT 8,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `PdfTemplate_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `EmailLog` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`fromAddress` VARCHAR(191) NOT NULL,
|
||||||
|
`toAddress` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NOT NULL,
|
||||||
|
`context` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` INTEGER NULL,
|
||||||
|
`triggeredBy` VARCHAR(191) NULL,
|
||||||
|
`smtpServer` VARCHAR(191) NOT NULL,
|
||||||
|
`smtpPort` INTEGER NOT NULL,
|
||||||
|
`smtpEncryption` VARCHAR(191) NOT NULL,
|
||||||
|
`smtpUser` VARCHAR(191) NOT NULL,
|
||||||
|
`success` BOOLEAN NOT NULL,
|
||||||
|
`messageId` VARCHAR(191) NULL,
|
||||||
|
`errorMessage` TEXT NULL,
|
||||||
|
`smtpResponse` TEXT NULL,
|
||||||
|
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `EmailLog_sentAt_idx`(`sentAt`),
|
||||||
|
INDEX `EmailLog_customerId_idx`(`customerId`),
|
||||||
|
INDEX `EmailLog_success_idx`(`success`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AppSetting` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`key` VARCHAR(191) NOT NULL,
|
||||||
|
`value` TEXT NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `AppSetting_key_key`(`key`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `User` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`password` VARCHAR(191) NOT NULL,
|
||||||
|
`firstName` VARCHAR(191) NOT NULL,
|
||||||
|
`lastName` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`tokenInvalidatedAt` DATETIME(3) NULL,
|
||||||
|
`passwordResetToken` VARCHAR(191) NULL,
|
||||||
|
`passwordResetExpiresAt` DATETIME(3) NULL,
|
||||||
|
`whatsappNumber` VARCHAR(191) NULL,
|
||||||
|
`telegramUsername` VARCHAR(191) NULL,
|
||||||
|
`signalNumber` VARCHAR(191) NULL,
|
||||||
|
`customerId` INTEGER NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `User_email_key`(`email`),
|
||||||
|
UNIQUE INDEX `User_passwordResetToken_key`(`passwordResetToken`),
|
||||||
|
UNIQUE INDEX `User_customerId_key`(`customerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Role` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Role_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Permission` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`resource` VARCHAR(191) NOT NULL,
|
||||||
|
`action` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `RolePermission` (
|
||||||
|
`roleId` INTEGER NOT NULL,
|
||||||
|
`permissionId` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`roleId`, `permissionId`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `UserRole` (
|
||||||
|
`userId` INTEGER NOT NULL,
|
||||||
|
`roleId` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`userId`, `roleId`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Customer` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
|
||||||
|
`salutation` VARCHAR(191) NULL,
|
||||||
|
`firstName` VARCHAR(191) NOT NULL,
|
||||||
|
`lastName` VARCHAR(191) NOT NULL,
|
||||||
|
`companyName` VARCHAR(191) NULL,
|
||||||
|
`foundingDate` DATETIME(3) NULL,
|
||||||
|
`birthDate` DATETIME(3) NULL,
|
||||||
|
`birthPlace` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`mobile` VARCHAR(191) NULL,
|
||||||
|
`taxNumber` VARCHAR(191) NULL,
|
||||||
|
`businessRegistrationPath` VARCHAR(191) NULL,
|
||||||
|
`commercialRegisterPath` VARCHAR(191) NULL,
|
||||||
|
`commercialRegisterNumber` VARCHAR(191) NULL,
|
||||||
|
`privacyPolicyPath` VARCHAR(191) NULL,
|
||||||
|
`consentHash` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`portalEnabled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`portalEmail` VARCHAR(191) NULL,
|
||||||
|
`portalPasswordHash` VARCHAR(191) NULL,
|
||||||
|
`portalPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`portalLastLogin` DATETIME(3) NULL,
|
||||||
|
`portalPasswordResetToken` VARCHAR(191) NULL,
|
||||||
|
`portalPasswordResetExpiresAt` DATETIME(3) NULL,
|
||||||
|
`portalTokenInvalidatedAt` DATETIME(3) NULL,
|
||||||
|
`lastBirthdayGreetingYear` INTEGER NULL,
|
||||||
|
`useInformalAddress` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`autoBirthdayGreeting` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`autoBirthdayChannel` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
|
||||||
|
UNIQUE INDEX `Customer_consentHash_key`(`consentHash`),
|
||||||
|
UNIQUE INDEX `Customer_portalEmail_key`(`portalEmail`),
|
||||||
|
UNIQUE INDEX `Customer_portalPasswordResetToken_key`(`portalPasswordResetToken`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CustomerRepresentative` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`representativeId` INTEGER NOT NULL,
|
||||||
|
`notes` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `RepresentativeAuthorization` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`representativeId` INTEGER NOT NULL,
|
||||||
|
`isGranted` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`grantedAt` DATETIME(3) NULL,
|
||||||
|
`withdrawnAt` DATETIME(3) NULL,
|
||||||
|
`source` VARCHAR(191) NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `RepresentativeAuthorization_customerId_representativeId_key`(`customerId`, `representativeId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Address` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
|
||||||
|
`street` VARCHAR(191) NOT NULL,
|
||||||
|
`houseNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`postalCode` VARCHAR(191) NOT NULL,
|
||||||
|
`city` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
|
||||||
|
`isDefault` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`ownerCompany` VARCHAR(191) NULL,
|
||||||
|
`ownerFirstName` VARCHAR(191) NULL,
|
||||||
|
`ownerLastName` VARCHAR(191) NULL,
|
||||||
|
`ownerStreet` VARCHAR(191) NULL,
|
||||||
|
`ownerHouseNumber` VARCHAR(191) NULL,
|
||||||
|
`ownerPostalCode` VARCHAR(191) NULL,
|
||||||
|
`ownerCity` VARCHAR(191) NULL,
|
||||||
|
`ownerPhone` VARCHAR(191) NULL,
|
||||||
|
`ownerMobile` VARCHAR(191) NULL,
|
||||||
|
`ownerEmail` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BankCard` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`accountHolder` VARCHAR(191) NOT NULL,
|
||||||
|
`iban` VARCHAR(191) NOT NULL,
|
||||||
|
`bic` VARCHAR(191) NULL,
|
||||||
|
`bankName` VARCHAR(191) NULL,
|
||||||
|
`expiryDate` DATETIME(3) NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `IdentityDocument` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
|
||||||
|
`documentNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`issuingAuthority` VARCHAR(191) NULL,
|
||||||
|
`issueDate` DATETIME(3) NULL,
|
||||||
|
`expiryDate` DATETIME(3) NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`licenseClasses` VARCHAR(191) NULL,
|
||||||
|
`licenseIssueDate` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `EmailProviderConfig` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
|
||||||
|
`apiUrl` VARCHAR(191) NOT NULL,
|
||||||
|
`apiKey` VARCHAR(191) NULL,
|
||||||
|
`username` VARCHAR(191) NULL,
|
||||||
|
`passwordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`domain` VARCHAR(191) NOT NULL,
|
||||||
|
`defaultForwardEmail` VARCHAR(191) NULL,
|
||||||
|
`imapServer` VARCHAR(191) NULL,
|
||||||
|
`imapPort` INTEGER NULL DEFAULT 993,
|
||||||
|
`smtpServer` VARCHAR(191) NULL,
|
||||||
|
`smtpPort` INTEGER NULL DEFAULT 465,
|
||||||
|
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
|
||||||
|
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
|
||||||
|
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`systemEmailAddress` VARCHAR(191) NULL,
|
||||||
|
`systemEmailPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`customerEmailLabel` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`isDefault` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `StressfreiEmail` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`platform` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`provisionedAt` DATETIME(3) NULL,
|
||||||
|
`provisionError` TEXT NULL,
|
||||||
|
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`emailPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CachedEmail` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`stressfreiEmailId` INTEGER NOT NULL,
|
||||||
|
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
|
||||||
|
`messageId` VARCHAR(191) NOT NULL,
|
||||||
|
`uid` INTEGER NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NULL,
|
||||||
|
`fromAddress` VARCHAR(191) NOT NULL,
|
||||||
|
`fromName` VARCHAR(191) NULL,
|
||||||
|
`toAddresses` TEXT NOT NULL,
|
||||||
|
`ccAddresses` TEXT NULL,
|
||||||
|
`receivedAt` DATETIME(3) NOT NULL,
|
||||||
|
`textBody` LONGTEXT NULL,
|
||||||
|
`htmlBody` LONGTEXT NULL,
|
||||||
|
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`attachmentNames` TEXT NULL,
|
||||||
|
`contractId` INTEGER NULL,
|
||||||
|
`assignedAt` DATETIME(3) NULL,
|
||||||
|
`assignedBy` INTEGER NULL,
|
||||||
|
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isRead` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isStarred` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`deletedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `CachedEmail_contractId_idx`(`contractId`),
|
||||||
|
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
|
||||||
|
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
|
||||||
|
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Meter` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`meterNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
|
||||||
|
`tariffModel` ENUM('SINGLE', 'DUAL') NOT NULL DEFAULT 'SINGLE',
|
||||||
|
`location` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `MeterReading` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`meterId` INTEGER NOT NULL,
|
||||||
|
`readingDate` DATETIME(3) NOT NULL,
|
||||||
|
`value` DOUBLE NOT NULL,
|
||||||
|
`valueNt` DOUBLE NULL,
|
||||||
|
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
|
||||||
|
`notes` VARCHAR(191) NULL,
|
||||||
|
`reportedBy` VARCHAR(191) NULL,
|
||||||
|
`status` ENUM('RECORDED', 'REPORTED', 'TRANSFERRED') NOT NULL DEFAULT 'RECORDED',
|
||||||
|
`transferredAt` DATETIME(3) NULL,
|
||||||
|
`transferredBy` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `SalesPlatform` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`contactInfo` TEXT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CancellationPeriod` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractDuration` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` VARCHAR(191) NOT NULL,
|
||||||
|
`description` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `ContractDuration_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Provider` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`portalUrl` VARCHAR(191) NULL,
|
||||||
|
`usernameFieldName` VARCHAR(191) NULL,
|
||||||
|
`passwordFieldName` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Provider_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Tariff` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`providerId` INTEGER NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractCategory` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`icon` VARCHAR(191) NULL,
|
||||||
|
`color` VARCHAR(191) NULL,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `ContractCategory_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Contract` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
|
||||||
|
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`contractCategoryId` INTEGER NULL,
|
||||||
|
`addressId` INTEGER NULL,
|
||||||
|
`billingAddressId` INTEGER NULL,
|
||||||
|
`bankCardId` INTEGER NULL,
|
||||||
|
`identityDocumentId` INTEGER NULL,
|
||||||
|
`salesPlatformId` INTEGER NULL,
|
||||||
|
`cancellationPeriodId` INTEGER NULL,
|
||||||
|
`contractDurationId` INTEGER NULL,
|
||||||
|
`previousContractId` INTEGER NULL,
|
||||||
|
`previousProviderId` INTEGER NULL,
|
||||||
|
`previousCustomerNumber` VARCHAR(191) NULL,
|
||||||
|
`previousContractNumber` VARCHAR(191) NULL,
|
||||||
|
`providerId` INTEGER NULL,
|
||||||
|
`tariffId` INTEGER NULL,
|
||||||
|
`providerName` VARCHAR(191) NULL,
|
||||||
|
`tariffName` VARCHAR(191) NULL,
|
||||||
|
`customerNumberAtProvider` VARCHAR(191) NULL,
|
||||||
|
`contractNumberAtProvider` VARCHAR(191) NULL,
|
||||||
|
`priceFirst12Months` VARCHAR(191) NULL,
|
||||||
|
`priceFrom13Months` VARCHAR(191) NULL,
|
||||||
|
`priceAfter24Months` VARCHAR(191) NULL,
|
||||||
|
`startDate` DATETIME(3) NULL,
|
||||||
|
`endDate` DATETIME(3) NULL,
|
||||||
|
`commission` DOUBLE NULL,
|
||||||
|
`cancellationLetterPath` VARCHAR(191) NULL,
|
||||||
|
`cancellationConfirmationPath` VARCHAR(191) NULL,
|
||||||
|
`cancellationLetterOptionsPath` VARCHAR(191) NULL,
|
||||||
|
`cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
|
||||||
|
`cancellationConfirmationDate` DATETIME(3) NULL,
|
||||||
|
`cancellationConfirmationOptionsDate` DATETIME(3) NULL,
|
||||||
|
`wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`portalUsername` VARCHAR(191) NULL,
|
||||||
|
`portalPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`stressfreiEmailId` INTEGER NULL,
|
||||||
|
`nextReviewDate` DATETIME(3) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
|
||||||
|
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractDocument` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`documentType` VARCHAR(191) NOT NULL,
|
||||||
|
`documentPath` VARCHAR(191) NOT NULL,
|
||||||
|
`originalName` VARCHAR(191) NOT NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`uploadedBy` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `ContractDocument_contractId_idx`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractHistoryEntry` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdBy` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractTask` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
|
||||||
|
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdBy` VARCHAR(191) NULL,
|
||||||
|
`completedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractTaskSubtask` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`taskId` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(191) NOT NULL,
|
||||||
|
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
|
||||||
|
`createdBy` VARCHAR(191) NULL,
|
||||||
|
`completedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `EnergyContractDetails` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`meterId` INTEGER NULL,
|
||||||
|
`maloId` VARCHAR(191) NULL,
|
||||||
|
`annualConsumption` DOUBLE NULL,
|
||||||
|
`annualConsumptionKwh` DOUBLE NULL,
|
||||||
|
`basePrice` DOUBLE NULL,
|
||||||
|
`unitPrice` DOUBLE NULL,
|
||||||
|
`unitPriceNt` DOUBLE NULL,
|
||||||
|
`bonus` DOUBLE NULL,
|
||||||
|
`previousProviderName` VARCHAR(191) NULL,
|
||||||
|
`previousCustomerNumber` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ContractMeter` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`energyContractDetailsId` INTEGER NOT NULL,
|
||||||
|
`meterId` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`installedAt` DATETIME(3) NULL,
|
||||||
|
`removedAt` DATETIME(3) NULL,
|
||||||
|
`finalReading` DOUBLE NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `ContractMeter_energyContractDetailsId_idx`(`energyContractDetailsId`),
|
||||||
|
UNIQUE INDEX `ContractMeter_energyContractDetailsId_meterId_key`(`energyContractDetailsId`, `meterId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Invoice` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`energyContractDetailsId` INTEGER NULL,
|
||||||
|
`contractId` INTEGER NULL,
|
||||||
|
`invoiceDate` DATETIME(3) NOT NULL,
|
||||||
|
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`notes` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
|
||||||
|
INDEX `Invoice_contractId_idx`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `InternetContractDetails` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`downloadSpeed` INTEGER NULL,
|
||||||
|
`uploadSpeed` INTEGER NULL,
|
||||||
|
`routerModel` VARCHAR(191) NULL,
|
||||||
|
`routerSerialNumber` VARCHAR(191) NULL,
|
||||||
|
`installationDate` DATETIME(3) NULL,
|
||||||
|
`internetUsername` VARCHAR(191) NULL,
|
||||||
|
`internetPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`propertyType` VARCHAR(191) NULL,
|
||||||
|
`propertyLocation` VARCHAR(191) NULL,
|
||||||
|
`connectionLocation` VARCHAR(191) NULL,
|
||||||
|
`homeId` VARCHAR(191) NULL,
|
||||||
|
`activationCode` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `PhoneNumber` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`internetContractDetailsId` INTEGER NOT NULL,
|
||||||
|
`phoneNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`isMain` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`sipUsername` VARCHAR(191) NULL,
|
||||||
|
`sipPasswordEncrypted` VARCHAR(191) NULL,
|
||||||
|
`sipServer` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `MobileContractDetails` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`requiresMultisim` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`dataVolume` DOUBLE NULL,
|
||||||
|
`includedMinutes` INTEGER NULL,
|
||||||
|
`includedSMS` INTEGER NULL,
|
||||||
|
`deviceModel` VARCHAR(191) NULL,
|
||||||
|
`deviceImei` VARCHAR(191) NULL,
|
||||||
|
`phoneNumber` VARCHAR(191) NULL,
|
||||||
|
`simCardNumber` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `SimCard` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`mobileDetailsId` INTEGER NOT NULL,
|
||||||
|
`phoneNumber` VARCHAR(191) NULL,
|
||||||
|
`simCardNumber` VARCHAR(191) NULL,
|
||||||
|
`pin` VARCHAR(191) NULL,
|
||||||
|
`puk` VARCHAR(191) NULL,
|
||||||
|
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isMain` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `TvContractDetails` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`receiverModel` VARCHAR(191) NULL,
|
||||||
|
`smartcardNumber` VARCHAR(191) NULL,
|
||||||
|
`package` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CarInsuranceDetails` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`contractId` INTEGER NOT NULL,
|
||||||
|
`licensePlate` VARCHAR(191) NULL,
|
||||||
|
`hsn` VARCHAR(191) NULL,
|
||||||
|
`tsn` VARCHAR(191) NULL,
|
||||||
|
`vin` VARCHAR(191) NULL,
|
||||||
|
`vehicleType` VARCHAR(191) NULL,
|
||||||
|
`firstRegistration` DATETIME(3) NULL,
|
||||||
|
`noClaimsClass` VARCHAR(191) NULL,
|
||||||
|
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
|
||||||
|
`deductiblePartial` DOUBLE NULL,
|
||||||
|
`deductibleFull` DOUBLE NULL,
|
||||||
|
`policyNumber` VARCHAR(191) NULL,
|
||||||
|
`previousInsurer` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AuditLog` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`userId` INTEGER NULL,
|
||||||
|
`userEmail` VARCHAR(191) NOT NULL,
|
||||||
|
`userRole` TEXT NULL,
|
||||||
|
`customerId` INTEGER NULL,
|
||||||
|
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
|
||||||
|
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
|
||||||
|
`resourceType` VARCHAR(191) NOT NULL,
|
||||||
|
`resourceId` VARCHAR(191) NULL,
|
||||||
|
`resourceLabel` VARCHAR(191) NULL,
|
||||||
|
`endpoint` VARCHAR(191) NOT NULL,
|
||||||
|
`httpMethod` VARCHAR(191) NOT NULL,
|
||||||
|
`ipAddress` VARCHAR(191) NOT NULL,
|
||||||
|
`userAgent` TEXT NULL,
|
||||||
|
`changesBefore` LONGTEXT NULL,
|
||||||
|
`changesAfter` LONGTEXT NULL,
|
||||||
|
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`dataSubjectId` INTEGER NULL,
|
||||||
|
`legalBasis` VARCHAR(191) NULL,
|
||||||
|
`success` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`errorMessage` TEXT NULL,
|
||||||
|
`durationMs` INTEGER NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`hash` VARCHAR(191) NULL,
|
||||||
|
`previousHash` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
INDEX `AuditLog_userId_idx`(`userId`),
|
||||||
|
INDEX `AuditLog_customerId_idx`(`customerId`),
|
||||||
|
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
|
||||||
|
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
|
||||||
|
INDEX `AuditLog_action_idx`(`action`),
|
||||||
|
INDEX `AuditLog_createdAt_idx`(`createdAt`),
|
||||||
|
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CustomerConsent` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
|
||||||
|
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`grantedAt` DATETIME(3) NULL,
|
||||||
|
`withdrawnAt` DATETIME(3) NULL,
|
||||||
|
`source` VARCHAR(191) NULL,
|
||||||
|
`documentPath` VARCHAR(191) NULL,
|
||||||
|
`version` VARCHAR(191) NULL,
|
||||||
|
`ipAddress` VARCHAR(191) NULL,
|
||||||
|
`createdBy` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `CustomerConsent_customerId_idx`(`customerId`),
|
||||||
|
INDEX `CustomerConsent_consentType_idx`(`consentType`),
|
||||||
|
INDEX `CustomerConsent_status_idx`(`status`),
|
||||||
|
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `DataDeletionRequest` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`customerId` INTEGER NOT NULL,
|
||||||
|
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`requestSource` VARCHAR(191) NOT NULL,
|
||||||
|
`requestedBy` VARCHAR(191) NOT NULL,
|
||||||
|
`processedAt` DATETIME(3) NULL,
|
||||||
|
`processedBy` VARCHAR(191) NULL,
|
||||||
|
`deletedData` LONGTEXT NULL,
|
||||||
|
`retainedData` LONGTEXT NULL,
|
||||||
|
`retentionReason` TEXT NULL,
|
||||||
|
`proofDocument` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
|
||||||
|
INDEX `DataDeletionRequest_status_idx`(`status`),
|
||||||
|
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AuditRetentionPolicy` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`resourceType` VARCHAR(191) NOT NULL,
|
||||||
|
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
|
||||||
|
`retentionDays` INTEGER NOT NULL,
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`legalBasis` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `SecurityEvent` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`type` ENUM('LOGIN_FAILED', 'LOGIN_SUCCESS', 'RATE_LIMIT_HIT', 'ACCESS_DENIED', 'SSRF_BLOCKED', 'PASSWORD_RESET_REQUEST', 'PASSWORD_RESET_CONFIRM', 'LOGOUT', 'TOKEN_REJECTED', 'PERMISSION_CHANGED', 'SUSPICIOUS') NOT NULL,
|
||||||
|
`severity` ENUM('INFO', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL,
|
||||||
|
`message` TEXT NOT NULL,
|
||||||
|
`ipAddress` VARCHAR(191) NULL,
|
||||||
|
`userId` INTEGER NULL,
|
||||||
|
`customerId` INTEGER NULL,
|
||||||
|
`userEmail` VARCHAR(191) NULL,
|
||||||
|
`endpoint` VARCHAR(191) NULL,
|
||||||
|
`details` JSON NULL,
|
||||||
|
`alerted` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`alertedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `SecurityEvent_type_createdAt_idx`(`type`, `createdAt`),
|
||||||
|
INDEX `SecurityEvent_severity_createdAt_idx`(`severity`, `createdAt`),
|
||||||
|
INDEX `SecurityEvent_ipAddress_createdAt_idx`(`ipAddress`, `createdAt`),
|
||||||
|
INDEX `SecurityEvent_alerted_severity_idx`(`alerted`, `severity`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractDocument` ADD CONSTRAINT `ContractDocument_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE `User` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`email` VARCHAR(191) NOT NULL,
|
|
||||||
`password` VARCHAR(191) NOT NULL,
|
|
||||||
`firstName` VARCHAR(191) NOT NULL,
|
|
||||||
`lastName` VARCHAR(191) NOT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`customerId` INTEGER NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `User_email_key`(`email`),
|
|
||||||
UNIQUE INDEX `User_customerId_key`(`customerId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Role` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`description` VARCHAR(191) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Role_name_key`(`name`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Permission` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`resource` VARCHAR(191) NOT NULL,
|
|
||||||
`action` VARCHAR(191) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `RolePermission` (
|
|
||||||
`roleId` INTEGER NOT NULL,
|
|
||||||
`permissionId` INTEGER NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`roleId`, `permissionId`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `UserRole` (
|
|
||||||
`userId` INTEGER NOT NULL,
|
|
||||||
`roleId` INTEGER NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`userId`, `roleId`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Customer` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
|
|
||||||
`salutation` VARCHAR(191) NULL,
|
|
||||||
`firstName` VARCHAR(191) NOT NULL,
|
|
||||||
`lastName` VARCHAR(191) NOT NULL,
|
|
||||||
`companyName` VARCHAR(191) NULL,
|
|
||||||
`email` VARCHAR(191) NULL,
|
|
||||||
`phone` VARCHAR(191) NULL,
|
|
||||||
`mobile` VARCHAR(191) NULL,
|
|
||||||
`taxNumber` VARCHAR(191) NULL,
|
|
||||||
`businessRegistration` TEXT NULL,
|
|
||||||
`commercialRegister` VARCHAR(191) NULL,
|
|
||||||
`notes` TEXT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Address` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
|
|
||||||
`street` VARCHAR(191) NOT NULL,
|
|
||||||
`houseNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`postalCode` VARCHAR(191) NOT NULL,
|
|
||||||
`city` VARCHAR(191) NOT NULL,
|
|
||||||
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
|
|
||||||
`isDefault` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `BankCard` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`accountHolder` VARCHAR(191) NOT NULL,
|
|
||||||
`iban` VARCHAR(191) NOT NULL,
|
|
||||||
`bic` VARCHAR(191) NULL,
|
|
||||||
`bankName` VARCHAR(191) NULL,
|
|
||||||
`expiryDate` DATETIME(3) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `IdentityDocument` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
|
|
||||||
`documentNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`issuingAuthority` VARCHAR(191) NULL,
|
|
||||||
`issueDate` DATETIME(3) NULL,
|
|
||||||
`expiryDate` DATETIME(3) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Meter` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`meterNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
|
|
||||||
`location` VARCHAR(191) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `MeterReading` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`meterId` INTEGER NOT NULL,
|
|
||||||
`readingDate` DATETIME(3) NOT NULL,
|
|
||||||
`value` DOUBLE NOT NULL,
|
|
||||||
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
|
|
||||||
`notes` VARCHAR(191) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `SalesPlatform` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`contactInfo` TEXT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Contract` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
|
|
||||||
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED') NOT NULL DEFAULT 'DRAFT',
|
|
||||||
`addressId` INTEGER NULL,
|
|
||||||
`bankCardId` INTEGER NULL,
|
|
||||||
`identityDocumentId` INTEGER NULL,
|
|
||||||
`salesPlatformId` INTEGER NULL,
|
|
||||||
`previousContractId` INTEGER NULL,
|
|
||||||
`providerName` VARCHAR(191) NULL,
|
|
||||||
`tariffName` VARCHAR(191) NULL,
|
|
||||||
`customerNumberAtProvider` VARCHAR(191) NULL,
|
|
||||||
`startDate` DATETIME(3) NULL,
|
|
||||||
`endDate` DATETIME(3) NULL,
|
|
||||||
`cancellationPeriod` INTEGER NULL,
|
|
||||||
`commission` DOUBLE NULL,
|
|
||||||
`portalUsername` VARCHAR(191) NULL,
|
|
||||||
`portalPasswordEncrypted` VARCHAR(191) NULL,
|
|
||||||
`notes` TEXT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
|
|
||||||
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `EnergyContractDetails` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`meterId` INTEGER NULL,
|
|
||||||
`annualConsumption` DOUBLE NULL,
|
|
||||||
`basePrice` DOUBLE NULL,
|
|
||||||
`unitPrice` DOUBLE NULL,
|
|
||||||
`bonus` DOUBLE NULL,
|
|
||||||
`previousProviderName` VARCHAR(191) NULL,
|
|
||||||
`previousCustomerNumber` VARCHAR(191) NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `InternetContractDetails` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`downloadSpeed` INTEGER NULL,
|
|
||||||
`uploadSpeed` INTEGER NULL,
|
|
||||||
`routerModel` VARCHAR(191) NULL,
|
|
||||||
`routerSerialNumber` VARCHAR(191) NULL,
|
|
||||||
`installationDate` DATETIME(3) NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `PhoneNumber` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`internetContractDetailsId` INTEGER NOT NULL,
|
|
||||||
`phoneNumber` VARCHAR(191) NOT NULL,
|
|
||||||
`isMain` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `MobileContractDetails` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`phoneNumber` VARCHAR(191) NULL,
|
|
||||||
`simCardNumber` VARCHAR(191) NULL,
|
|
||||||
`dataVolume` DOUBLE NULL,
|
|
||||||
`includedMinutes` INTEGER NULL,
|
|
||||||
`includedSMS` INTEGER NULL,
|
|
||||||
`deviceModel` VARCHAR(191) NULL,
|
|
||||||
`deviceImei` VARCHAR(191) NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `TvContractDetails` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`receiverModel` VARCHAR(191) NULL,
|
|
||||||
`smartcardNumber` VARCHAR(191) NULL,
|
|
||||||
`package` VARCHAR(191) NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `CarInsuranceDetails` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`licensePlate` VARCHAR(191) NULL,
|
|
||||||
`hsn` VARCHAR(191) NULL,
|
|
||||||
`tsn` VARCHAR(191) NULL,
|
|
||||||
`vin` VARCHAR(191) NULL,
|
|
||||||
`vehicleType` VARCHAR(191) NULL,
|
|
||||||
`firstRegistration` DATETIME(3) NULL,
|
|
||||||
`noClaimsClass` VARCHAR(191) NULL,
|
|
||||||
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
|
|
||||||
`deductiblePartial` DOUBLE NULL,
|
|
||||||
`deductibleFull` DOUBLE NULL,
|
|
||||||
`policyNumber` VARCHAR(191) NULL,
|
|
||||||
`previousInsurer` VARCHAR(191) NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `BankCard` ADD COLUMN `documentPath` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `IdentityDocument` ADD COLUMN `documentPath` VARCHAR(191) NULL;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` ADD COLUMN `birthDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `birthPlace` VARCHAR(191) NULL;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `IdentityDocument` ADD COLUMN `licenseClasses` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `licenseIssueDate` DATETIME(3) NULL;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `businessRegistration` on the `Customer` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `commercialRegister` on the `Customer` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` DROP COLUMN `businessRegistration`,
|
|
||||||
DROP COLUMN `commercialRegister`,
|
|
||||||
ADD COLUMN `businessRegistrationPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `commercialRegisterNumber` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `commercialRegisterPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `foundingDate` DATETIME(3) NULL;
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `cancellationPeriod` on the `Contract` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` DROP COLUMN `cancellationPeriod`,
|
|
||||||
ADD COLUMN `cancellationPeriodId` INTEGER NULL,
|
|
||||||
ADD COLUMN `priceAfter24Months` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `priceFirst12Months` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `priceFrom13Months` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` ADD COLUMN `privacyPolicyPath` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `CancellationPeriod` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`code` VARCHAR(191) NOT NULL,
|
|
||||||
`description` VARCHAR(191) NOT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `contractDurationId` INTEGER NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `ContractDuration` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`code` VARCHAR(191) NOT NULL,
|
|
||||||
`description` VARCHAR(191) NOT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `ContractDuration_code_key`(`code`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `cancellationConfirmationDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `cancellationConfirmationOptionsDate` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `cancellationConfirmationPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `cancellationLetterOptionsPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `cancellationLetterPath` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `providerId` INTEGER NULL,
|
|
||||||
ADD COLUMN `tariffId` INTEGER NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Provider` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`portalUrl` VARCHAR(191) NULL,
|
|
||||||
`usernameFieldName` VARCHAR(191) NULL,
|
|
||||||
`passwordFieldName` VARCHAR(191) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Provider_name_key`(`name`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Tariff` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`providerId` INTEGER NOT NULL,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `MobileContractDetails` ADD COLUMN `requiresMultisim` BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `SimCard` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`mobileDetailsId` INTEGER NOT NULL,
|
|
||||||
`phoneNumber` VARCHAR(191) NULL,
|
|
||||||
`simCardNumber` VARCHAR(191) NULL,
|
|
||||||
`pin` VARCHAR(191) NULL,
|
|
||||||
`puk` VARCHAR(191) NULL,
|
|
||||||
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`isMain` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `contractCategoryId` INTEGER NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `ContractCategory` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`code` VARCHAR(191) NOT NULL,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`icon` VARCHAR(191) NULL,
|
|
||||||
`color` VARCHAR(191) NULL,
|
|
||||||
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `ContractCategory_code_key`(`code`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` MODIFY `type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `InternetContractDetails` ADD COLUMN `activationCode` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `homeId` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `internetPasswordEncrypted` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `internetUsername` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `PhoneNumber` ADD COLUMN `sipPasswordEncrypted` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `sipServer` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `sipUsername` VARCHAR(191) NULL;
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[portalEmail]` on the table `Customer` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `stressfreiEmailId` INTEGER NULL,
|
|
||||||
MODIFY `status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT';
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` ADD COLUMN `portalEmail` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `portalEnabled` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN `portalLastLogin` DATETIME(3) NULL,
|
|
||||||
ADD COLUMN `portalPasswordEncrypted` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `portalPasswordHash` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `AppSetting` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`key` VARCHAR(191) NOT NULL,
|
|
||||||
`value` TEXT NOT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `AppSetting_key_key`(`key`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `CustomerRepresentative` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`representativeId` INTEGER NOT NULL,
|
|
||||||
`notes` VARCHAR(191) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `EmailProviderConfig` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
|
|
||||||
`apiUrl` VARCHAR(191) NOT NULL,
|
|
||||||
`apiKey` VARCHAR(191) NULL,
|
|
||||||
`username` VARCHAR(191) NULL,
|
|
||||||
`passwordEncrypted` VARCHAR(191) NULL,
|
|
||||||
`domain` VARCHAR(191) NOT NULL,
|
|
||||||
`defaultForwardEmail` VARCHAR(191) NULL,
|
|
||||||
`imapServer` VARCHAR(191) NULL,
|
|
||||||
`imapPort` INTEGER NULL DEFAULT 993,
|
|
||||||
`smtpServer` VARCHAR(191) NULL,
|
|
||||||
`smtpPort` INTEGER NULL DEFAULT 465,
|
|
||||||
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
|
|
||||||
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
|
|
||||||
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`isDefault` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `StressfreiEmail` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`email` VARCHAR(191) NOT NULL,
|
|
||||||
`platform` VARCHAR(191) NULL,
|
|
||||||
`notes` TEXT NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`provisionedAt` DATETIME(3) NULL,
|
|
||||||
`provisionError` TEXT NULL,
|
|
||||||
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`emailPasswordEncrypted` VARCHAR(191) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `CachedEmail` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`stressfreiEmailId` INTEGER NOT NULL,
|
|
||||||
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
|
|
||||||
`messageId` VARCHAR(191) NOT NULL,
|
|
||||||
`uid` INTEGER NOT NULL,
|
|
||||||
`subject` VARCHAR(191) NULL,
|
|
||||||
`fromAddress` VARCHAR(191) NOT NULL,
|
|
||||||
`fromName` VARCHAR(191) NULL,
|
|
||||||
`toAddresses` TEXT NOT NULL,
|
|
||||||
`ccAddresses` TEXT NULL,
|
|
||||||
`receivedAt` DATETIME(3) NOT NULL,
|
|
||||||
`textBody` LONGTEXT NULL,
|
|
||||||
`htmlBody` LONGTEXT NULL,
|
|
||||||
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`attachmentNames` TEXT NULL,
|
|
||||||
`contractId` INTEGER NULL,
|
|
||||||
`assignedAt` DATETIME(3) NULL,
|
|
||||||
`assignedBy` INTEGER NULL,
|
|
||||||
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`isRead` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`isStarred` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`deletedAt` DATETIME(3) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
INDEX `CachedEmail_contractId_idx`(`contractId`),
|
|
||||||
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
|
|
||||||
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
|
|
||||||
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `ContractTask` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`title` VARCHAR(191) NOT NULL,
|
|
||||||
`description` TEXT NULL,
|
|
||||||
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
|
|
||||||
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdBy` VARCHAR(191) NULL,
|
|
||||||
`completedAt` DATETIME(3) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `ContractTaskSubtask` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`taskId` INTEGER NOT NULL,
|
|
||||||
`title` VARCHAR(191) NOT NULL,
|
|
||||||
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
|
|
||||||
`createdBy` VARCHAR(191) NULL,
|
|
||||||
`completedAt` DATETIME(3) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX `Customer_portalEmail_key` ON `Customer`(`portalEmail`);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `User` ADD COLUMN `tokenInvalidatedAt` DATETIME(3) NULL;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `billingAddressId` INTEGER NULL;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `EnergyContractDetails` ADD COLUMN `annualConsumptionKwh` DOUBLE NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `EnergyContractDetails` ADD COLUMN `maloId` VARCHAR(191) NULL;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Invoice` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`energyContractDetailsId` INTEGER NOT NULL,
|
|
||||||
`invoiceDate` DATETIME(3) NOT NULL,
|
|
||||||
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
|
|
||||||
`documentPath` VARCHAR(191) NULL,
|
|
||||||
`notes` VARCHAR(191) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `nextReviewDate` DATETIME(3) NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `contractNumberAtProvider` VARCHAR(191) NULL;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Contract` ADD COLUMN `previousContractNumber` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `previousCustomerNumber` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `previousProviderId` INTEGER NULL;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE `ContractHistoryEntry` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`contractId` INTEGER NOT NULL,
|
|
||||||
`title` VARCHAR(191) NOT NULL,
|
|
||||||
`description` TEXT NULL,
|
|
||||||
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`createdBy` VARCHAR(191) NOT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE `AuditLog` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`userId` INTEGER NULL,
|
|
||||||
`userEmail` VARCHAR(191) NOT NULL,
|
|
||||||
`userRole` VARCHAR(191) NULL,
|
|
||||||
`customerId` INTEGER NULL,
|
|
||||||
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
|
|
||||||
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
|
|
||||||
`resourceType` VARCHAR(191) NOT NULL,
|
|
||||||
`resourceId` VARCHAR(191) NULL,
|
|
||||||
`resourceLabel` VARCHAR(191) NULL,
|
|
||||||
`endpoint` VARCHAR(191) NOT NULL,
|
|
||||||
`httpMethod` VARCHAR(191) NOT NULL,
|
|
||||||
`ipAddress` VARCHAR(191) NOT NULL,
|
|
||||||
`userAgent` TEXT NULL,
|
|
||||||
`changesBefore` LONGTEXT NULL,
|
|
||||||
`changesAfter` LONGTEXT NULL,
|
|
||||||
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
`dataSubjectId` INTEGER NULL,
|
|
||||||
`legalBasis` VARCHAR(191) NULL,
|
|
||||||
`success` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`errorMessage` TEXT NULL,
|
|
||||||
`durationMs` INTEGER NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`hash` VARCHAR(191) NULL,
|
|
||||||
`previousHash` VARCHAR(191) NULL,
|
|
||||||
|
|
||||||
INDEX `AuditLog_userId_idx`(`userId`),
|
|
||||||
INDEX `AuditLog_customerId_idx`(`customerId`),
|
|
||||||
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
|
|
||||||
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
|
|
||||||
INDEX `AuditLog_action_idx`(`action`),
|
|
||||||
INDEX `AuditLog_createdAt_idx`(`createdAt`),
|
|
||||||
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `CustomerConsent` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
|
|
||||||
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
|
|
||||||
`grantedAt` DATETIME(3) NULL,
|
|
||||||
`withdrawnAt` DATETIME(3) NULL,
|
|
||||||
`source` VARCHAR(191) NULL,
|
|
||||||
`documentPath` VARCHAR(191) NULL,
|
|
||||||
`version` VARCHAR(191) NULL,
|
|
||||||
`ipAddress` VARCHAR(191) NULL,
|
|
||||||
`createdBy` VARCHAR(191) NOT NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
INDEX `CustomerConsent_customerId_idx`(`customerId`),
|
|
||||||
INDEX `CustomerConsent_consentType_idx`(`consentType`),
|
|
||||||
INDEX `CustomerConsent_status_idx`(`status`),
|
|
||||||
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `DataDeletionRequest` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`customerId` INTEGER NOT NULL,
|
|
||||||
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
|
|
||||||
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`requestSource` VARCHAR(191) NOT NULL,
|
|
||||||
`requestedBy` VARCHAR(191) NOT NULL,
|
|
||||||
`processedAt` DATETIME(3) NULL,
|
|
||||||
`processedBy` VARCHAR(191) NULL,
|
|
||||||
`deletedData` LONGTEXT NULL,
|
|
||||||
`retainedData` LONGTEXT NULL,
|
|
||||||
`retentionReason` TEXT NULL,
|
|
||||||
`proofDocument` VARCHAR(191) NULL,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
|
|
||||||
INDEX `DataDeletionRequest_status_idx`(`status`),
|
|
||||||
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `AuditRetentionPolicy` (
|
|
||||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
`resourceType` VARCHAR(191) NOT NULL,
|
|
||||||
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
|
|
||||||
`retentionDays` INTEGER NOT NULL,
|
|
||||||
`description` VARCHAR(191) NULL,
|
|
||||||
`legalBasis` VARCHAR(191) NULL,
|
|
||||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` ADD COLUMN `consentHash` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `User` ADD COLUMN `whatsappNumber` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `telegramUsername` VARCHAR(191) NULL,
|
|
||||||
ADD COLUMN `signalNumber` VARCHAR(191) NULL;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX `Customer_consentHash_key` ON `Customer`(`consentHash`);
|
|
||||||
@@ -1113,3 +1113,53 @@ model AuditRetentionPolicy {
|
|||||||
|
|
||||||
@@unique([resourceType, sensitivity])
|
@@unique([resourceType, sensitivity])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SECURITY MONITORING ====================
|
||||||
|
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
|
||||||
|
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
|
||||||
|
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
|
||||||
|
// effizient querybar). Threshold-Detection läuft per Cron.
|
||||||
|
|
||||||
|
enum SecurityEventType {
|
||||||
|
LOGIN_FAILED // falsches Passwort / unbekannter User
|
||||||
|
LOGIN_SUCCESS // erfolgreicher Login (informativ)
|
||||||
|
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
|
||||||
|
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
|
||||||
|
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
|
||||||
|
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
|
||||||
|
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
|
||||||
|
LOGOUT // expliziter Logout
|
||||||
|
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||||
|
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||||
|
SUSPICIOUS // generischer Catch-All
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SecuritySeverity {
|
||||||
|
INFO // Login-Success, Logout
|
||||||
|
LOW // Einzelner failed Login, einzelner 403
|
||||||
|
MEDIUM // Rate-Limit-Hit, mehrere 403er
|
||||||
|
HIGH // SSRF-Block, JWT-Manipulation
|
||||||
|
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
||||||
|
}
|
||||||
|
|
||||||
|
model SecurityEvent {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type SecurityEventType
|
||||||
|
severity SecuritySeverity
|
||||||
|
message String @db.Text
|
||||||
|
ipAddress String?
|
||||||
|
userId Int? // Mitarbeiter (falls eingeloggt)
|
||||||
|
customerId Int? // Portal-Kunde (falls eingeloggt)
|
||||||
|
userEmail String? // beste Schätzung – auch bei nicht eingeloggt
|
||||||
|
endpoint String? // betroffener Endpoint
|
||||||
|
details Json? // strukturierte Zusatzinfo
|
||||||
|
alerted Boolean @default(false) // schon per Email versendet?
|
||||||
|
alertedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([type, createdAt])
|
||||||
|
@@index([severity, createdAt])
|
||||||
|
@@index([ipAddress, createdAt])
|
||||||
|
@@index([alerted, severity])
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const ROOT = path.join(process.cwd(), 'factory-defaults');
|
// ROOT kann via FACTORY_DEFAULTS_DIR überschrieben werden (Container-Bootstrap
|
||||||
|
// mit eingebauten Defaults aus dem Image).
|
||||||
|
const ROOT = process.env.FACTORY_DEFAULTS_DIR
|
||||||
|
? path.resolve(process.env.FACTORY_DEFAULTS_DIR)
|
||||||
|
: path.join(process.cwd(), 'factory-defaults');
|
||||||
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
|
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
|
||||||
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
|
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
|
||||||
|
|
||||||
@@ -61,6 +65,19 @@ interface PdfTemplateDef {
|
|||||||
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppSettingDef {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist – muss synchron zu factoryDefaults.service.ts sein.
|
||||||
|
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
||||||
*/
|
*/
|
||||||
@@ -299,6 +316,31 @@ async function seedPdfTemplates() {
|
|||||||
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAppSettings() {
|
||||||
|
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log(' app-settings – keine Einträge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const s of items) {
|
||||||
|
if (!s.key || typeof s.value !== 'string') continue;
|
||||||
|
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
|
||||||
|
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist – übersprungen`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: { value: s.value },
|
||||||
|
create: { key: s.key, value: s.value },
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
||||||
|
|
||||||
@@ -313,6 +355,7 @@ async function main() {
|
|||||||
await seedContractDurations();
|
await seedContractDurations();
|
||||||
await seedContractCategories();
|
await seedContractCategories();
|
||||||
await seedPdfTemplates();
|
await seedPdfTemplates();
|
||||||
|
await seedAppSettings();
|
||||||
|
|
||||||
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
export async function login(req: Request, res: Response): Promise<void> {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -16,8 +18,25 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.login(email, password);
|
const result = await authService.login(email, password);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Mitarbeiter-Login: ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: result.user.id,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_FAILED',
|
||||||
|
severity: 'LOW',
|
||||||
|
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
@@ -27,9 +46,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
// Kundenportal-Login
|
// Kundenportal-Login
|
||||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||||
|
const { email, password } = req.body || {};
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -39,8 +58,25 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.customerLogin(email, password);
|
const result = await authService.customerLogin(email, password);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Portal-Login: ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
customerId: result.user.customerId,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGIN_FAILED',
|
||||||
|
severity: 'LOW',
|
||||||
|
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
@@ -114,6 +150,17 @@ export async function requestPasswordReset(req: Request, res: Response): Promise
|
|||||||
|
|
||||||
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
|
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
|
||||||
|
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PASSWORD_RESET_REQUEST',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
|
||||||
|
});
|
||||||
|
|
||||||
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
|
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -154,11 +201,28 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
|
|
||||||
await authService.confirmPasswordReset(token, password);
|
await authService.confirmPasswordReset(token, password);
|
||||||
|
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PASSWORD_RESET_CONFIRM',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: 'Passwort-Reset abgeschlossen',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'TOKEN_REJECTED',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
||||||
@@ -166,6 +230,53 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
|
||||||
|
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
|
||||||
|
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
|
||||||
|
*
|
||||||
|
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
|
||||||
|
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
|
||||||
|
* (auch andere Geräte) – akzeptabel für ein Sicherheits-Logout.
|
||||||
|
*/
|
||||||
|
export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = req.user as any;
|
||||||
|
if (!user) {
|
||||||
|
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (user.isCustomerPortal && user.customerId) {
|
||||||
|
await prisma.customer.update({
|
||||||
|
where: { id: user.customerId },
|
||||||
|
data: { portalTokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else if (user.userId) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'LOGOUT',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
customerId: ctx.customerId,
|
||||||
|
userEmail: user.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
});
|
||||||
|
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehler beim Abmelden',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function register(req: Request, res: Response): Promise<void> {
|
export async function register(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||||
|
|||||||
@@ -256,6 +256,58 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VVL = Vertragsverlängerung beim selben Anbieter.
|
||||||
|
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
|
||||||
|
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
|
||||||
|
*/
|
||||||
|
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const previousContractId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
const previousContract = await prisma.contract.findUnique({
|
||||||
|
where: { id: previousContractId },
|
||||||
|
select: { contractNumber: true },
|
||||||
|
});
|
||||||
|
if (!previousContract) {
|
||||||
|
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await contractService.createRenewalContract(previousContractId);
|
||||||
|
if (!contract) {
|
||||||
|
res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createdBy = req.user?.email || 'unbekannt';
|
||||||
|
|
||||||
|
await contractHistoryService.createRenewalHistoryEntry(
|
||||||
|
previousContractId,
|
||||||
|
contract.contractNumber,
|
||||||
|
createdBy,
|
||||||
|
);
|
||||||
|
await contractHistoryService.createNewRenewalFromPredecessorEntry(
|
||||||
|
contract.id,
|
||||||
|
previousContract.contractNumber,
|
||||||
|
createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req, action: 'CREATE', resourceType: 'Contract',
|
||||||
|
resourceId: contract.id.toString(),
|
||||||
|
label: `VVL erstellt für ${previousContract.contractNumber}`,
|
||||||
|
customerId: contract.customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { ApiResponse } from '../types/index.js';
|
|||||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
|
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -118,6 +120,33 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
domain: req.body.domain,
|
domain: req.body.domain,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
||||||
|
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass
|
||||||
|
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
||||||
|
if (testData?.apiUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(testData.apiUrl);
|
||||||
|
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'SSRF_BLOCKED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: err.message,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { apiUrl: testData.apiUrl },
|
||||||
|
});
|
||||||
|
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// URL-Parse-Fehler ignorieren – Backend reagiert sowieso mit Fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -214,24 +243,56 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
|
||||||
|
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||||
|
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||||
|
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
||||||
|
let smtpResolved: { ip: string; servername: string };
|
||||||
|
let imapResolved: { ip: string; servername: string };
|
||||||
|
try {
|
||||||
|
[smtpResolved, imapResolved] = await Promise.all([
|
||||||
|
safeResolveHost(smtpServer, 'SMTP-Server'),
|
||||||
|
safeResolveHost(imapServer, 'IMAP-Server'),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'SSRF_BLOCKED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { smtpServer, imapServer },
|
||||||
|
});
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// IMAP testen
|
// IMAP testen
|
||||||
const imapCredentials: ImapCredentials = {
|
const imapCredentials: ImapCredentials = {
|
||||||
host: imapServer,
|
host: imapResolved.ip,
|
||||||
port: imapPort,
|
port: imapPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: imapEncryption,
|
encryption: imapEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
|
servername: imapResolved.servername,
|
||||||
};
|
};
|
||||||
|
|
||||||
// SMTP testen
|
// SMTP testen
|
||||||
const smtpCredentials: SmtpCredentials = {
|
const smtpCredentials: SmtpCredentials = {
|
||||||
host: smtpServer,
|
host: smtpResolved.ip,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: smtpEncryption,
|
encryption: smtpEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
|
servername: smtpResolved.servername,
|
||||||
};
|
};
|
||||||
|
|
||||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
|
appSettings: data.appSettings.length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -62,3 +63,39 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
|
||||||
|
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
|
||||||
|
*/
|
||||||
|
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const file = (req as any).file as Express.Multer.File | undefined;
|
||||||
|
if (!file || !file.buffer) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email || 'unknown',
|
||||||
|
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
|
||||||
|
// den Vorgang explizit als Import.
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'FactoryDefaults',
|
||||||
|
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
|
||||||
|
endpoint: req.path,
|
||||||
|
httpMethod: req.method,
|
||||||
|
ipAddress: req.socket.remoteAddress || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Factory-Defaults-Import:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
import { findUploadOwner } from '../services/fileDownload.service.js';
|
||||||
|
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
|
||||||
|
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
|
||||||
|
*
|
||||||
|
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
|
||||||
|
*
|
||||||
|
* Schritte:
|
||||||
|
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
|
||||||
|
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
|
||||||
|
* 3. canAccessCustomer / canAccessContract / Permission-Check
|
||||||
|
* 4. Datei senden (mit korrektem Content-Type)
|
||||||
|
*
|
||||||
|
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
|
||||||
|
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
|
||||||
|
* vertretenen Kunden mit Vollmacht) herunterladen – nicht mehr beliebige
|
||||||
|
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
|
||||||
|
* mitgeschnitten hätte.
|
||||||
|
*/
|
||||||
|
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
const requested = typeof req.query.path === 'string' ? req.query.path : '';
|
||||||
|
if (!requested) {
|
||||||
|
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format-Validierung (Traversal-Schutz)
|
||||||
|
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner ermitteln
|
||||||
|
const owner = await findUploadOwner(requested);
|
||||||
|
if (!owner) {
|
||||||
|
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access-Check je nach Owner-Typ
|
||||||
|
if (owner.kind === 'customer') {
|
||||||
|
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||||||
|
} else if (owner.kind === 'contract') {
|
||||||
|
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||||||
|
} else if (owner.kind === 'admin') {
|
||||||
|
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||||||
|
const perms = req.user?.permissions || [];
|
||||||
|
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
|
||||||
|
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (owner.kind === 'gdpr-admin') {
|
||||||
|
const perms = req.user?.permissions || [];
|
||||||
|
if (!perms.includes('gdpr:admin')) {
|
||||||
|
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei vom Disk lesen
|
||||||
|
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
|
||||||
|
const relative = requested.substring('/uploads/'.length);
|
||||||
|
const absolute = path.join(process.cwd(), 'uploads', relative);
|
||||||
|
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
|
||||||
|
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
|
||||||
|
if (!absolute.startsWith(uploadsRoot)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(absolute)) {
|
||||||
|
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.sendFile(absolute);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
|
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
|
||||||
|
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/monitoring/events
|
||||||
|
* Liste der Security-Events mit Filter + Pagination.
|
||||||
|
*/
|
||||||
|
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const page = parseInt((req.query.page as string) || '1');
|
||||||
|
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
|
||||||
|
const type = req.query.type as SecurityEventType | undefined;
|
||||||
|
const severity = req.query.severity as SecuritySeverity | undefined;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const since = req.query.since as string | undefined;
|
||||||
|
const ip = req.query.ip as string | undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (severity) where.severity = severity;
|
||||||
|
if (ip) where.ipAddress = ip;
|
||||||
|
if (since) where.createdAt = { gte: new Date(since) };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ message: { contains: search } },
|
||||||
|
{ userEmail: { contains: search } },
|
||||||
|
{ endpoint: { contains: search } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, total, byType, bySeverity] = await Promise.all([
|
||||||
|
prisma.securityEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
}),
|
||||||
|
prisma.securityEvent.count({ where }),
|
||||||
|
prisma.securityEvent.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.securityEvent.groupBy({
|
||||||
|
by: ['severity'],
|
||||||
|
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: events,
|
||||||
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||||
|
stats: {
|
||||||
|
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
|
||||||
|
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('listEvents error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/monitoring/settings
|
||||||
|
*/
|
||||||
|
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||||
|
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
alertEmail: alertEmail || '',
|
||||||
|
digestEnabled,
|
||||||
|
lastDigestAt: lastDigest || null,
|
||||||
|
},
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/monitoring/settings
|
||||||
|
*/
|
||||||
|
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { alertEmail, digestEnabled } = req.body || {};
|
||||||
|
if (typeof alertEmail === 'string') {
|
||||||
|
// Email-Validierung minimal: muss @ enthalten oder leer sein
|
||||||
|
if (alertEmail !== '' && !alertEmail.includes('@')) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
|
||||||
|
}
|
||||||
|
if (typeof digestEnabled === 'boolean') {
|
||||||
|
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/monitoring/test-alert
|
||||||
|
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
|
||||||
|
*/
|
||||||
|
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Keine Alert-E-Mail konfiguriert',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: '[OpenCRM] Test-Alert',
|
||||||
|
events: [{
|
||||||
|
type: 'SUSPICIOUS' as any,
|
||||||
|
severity: 'INFO' as any,
|
||||||
|
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as any],
|
||||||
|
isDigest: false,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/monitoring/events
|
||||||
|
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
|
||||||
|
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
|
||||||
|
* Audit-Trail erhalten bleibt.
|
||||||
|
*/
|
||||||
|
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const olderThanDays = req.query.olderThanDays
|
||||||
|
? parseInt(req.query.olderThanDays as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (olderThanDays && olderThanDays > 0) {
|
||||||
|
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
||||||
|
where.createdAt = { lt: cutoff };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.securityEvent.deleteMany({ where });
|
||||||
|
|
||||||
|
// Audit-Spur: Wer hat geleert
|
||||||
|
const user = (req as any).user;
|
||||||
|
await prisma.securityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'INFO',
|
||||||
|
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
|
||||||
|
userId: user?.userId || null,
|
||||||
|
userEmail: user?.email || null,
|
||||||
|
ipAddress: req.ip || 'unknown',
|
||||||
|
endpoint: 'DELETE /api/monitoring/events',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${result.count} Events gelöscht`,
|
||||||
|
data: { deletedCount: result.count },
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('clearEvents error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
|
||||||
|
*/
|
||||||
|
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await sendDigest({ force: true });
|
||||||
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,8 +72,19 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body);
|
const data = pickUserUpdate(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
||||||
const before = await prisma.user.findUnique({ where: { id: userId } });
|
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
||||||
|
const beforeUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { roles: { include: { role: true } } },
|
||||||
|
});
|
||||||
|
const before = beforeUser
|
||||||
|
? {
|
||||||
|
...beforeUser,
|
||||||
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data as any);
|
const user = await userService.updateUser(userId, data as any);
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -82,6 +93,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
|
|||||||
+141
-12
@@ -3,6 +3,31 @@ import cors from 'cors';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import dotenvExpand from 'dotenv-expand';
|
||||||
|
|
||||||
|
// .env-Dateien laden – Root-.env hat Priorität (zentrale Konfiguration für
|
||||||
|
// Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind
|
||||||
|
// Variablen schon via env_file/environment gesetzt – dotenv überschreibt
|
||||||
|
// existierende process.env-Werte nicht.
|
||||||
|
// __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen
|
||||||
|
// liegt Root /.env zwei Ebenen darüber.
|
||||||
|
//
|
||||||
|
// dotenvExpand löst ${VAR}-Substitution auf, sodass z.B.
|
||||||
|
// DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
// dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen).
|
||||||
|
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') }));
|
||||||
|
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') }));
|
||||||
|
dotenvExpand.expand(dotenv.config());
|
||||||
|
|
||||||
|
// Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution
|
||||||
|
// nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen.
|
||||||
|
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
|
||||||
|
const u = encodeURIComponent(process.env.DB_USER);
|
||||||
|
const p = encodeURIComponent(process.env.DB_PASSWORD);
|
||||||
|
const h = process.env.DB_HOST || 'localhost';
|
||||||
|
const port = process.env.DB_PORT || '3306';
|
||||||
|
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
||||||
|
}
|
||||||
|
|
||||||
import authRoutes from './routes/auth.routes.js';
|
import authRoutes from './routes/auth.routes.js';
|
||||||
import customerRoutes from './routes/customer.routes.js';
|
import customerRoutes from './routes/customer.routes.js';
|
||||||
@@ -34,14 +59,15 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
|||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.routes.js';
|
import birthdayRoutes from './routes/birthday.routes.js';
|
||||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
|
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||||
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
|
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
|
||||||
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
|
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
|
||||||
@@ -70,12 +96,97 @@ app.set('trust proxy', 'loopback');
|
|||||||
|
|
||||||
// ==================== SECURITY MIDDLEWARE ====================
|
// ==================== SECURITY MIDDLEWARE ====================
|
||||||
|
|
||||||
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.)
|
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...)
|
||||||
|
//
|
||||||
|
// CSP ist konservativ aber SPA-tauglich:
|
||||||
|
// - script-src 'self' → keine externen Skripte, keine inline-Scripts
|
||||||
|
// (Vite baut Module-Skripte zu separaten Files,
|
||||||
|
// die sind 'self')
|
||||||
|
// - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das
|
||||||
|
// (sicheres Trade-off; XSS via CSS ist
|
||||||
|
// marginal vs Lock-Out gegen die UI)
|
||||||
|
// - img-src self/data/blob → base64-Avatare + blob-URLs für PDFs/Downloads
|
||||||
|
// - font-src self/data → eingebettete Fonts
|
||||||
|
// - connect-src 'self' → API + WebSocket nur zur eigenen Origin
|
||||||
|
// - frame-ancestors 'none' → Clickjacking-Schutz (ersetzt X-Frame-Options)
|
||||||
|
// - object-src 'none' → keine Flash/<object>/<embed>-Embeds
|
||||||
|
// - base-uri 'self' → keine <base>-Hijacking-Tricks
|
||||||
|
// - form-action 'self' → POST-Targets nur auf eigene Origin
|
||||||
|
// Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen.
|
||||||
|
// Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc.
|
||||||
|
// bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten,
|
||||||
|
// fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet.
|
||||||
|
app.use((_req, res, next) => {
|
||||||
|
res.setHeader(
|
||||||
|
'Permissions-Policy',
|
||||||
|
[
|
||||||
|
'accelerometer=()',
|
||||||
|
'ambient-light-sensor=()',
|
||||||
|
'autoplay=()',
|
||||||
|
'battery=()',
|
||||||
|
'camera=()',
|
||||||
|
'clipboard-read=()',
|
||||||
|
'clipboard-write=(self)',
|
||||||
|
'cross-origin-isolated=()',
|
||||||
|
'display-capture=()',
|
||||||
|
'encrypted-media=()',
|
||||||
|
'fullscreen=(self)',
|
||||||
|
'geolocation=()',
|
||||||
|
'gyroscope=()',
|
||||||
|
'hid=()',
|
||||||
|
'idle-detection=()',
|
||||||
|
'magnetometer=()',
|
||||||
|
'microphone=()',
|
||||||
|
'midi=()',
|
||||||
|
'payment=()',
|
||||||
|
'picture-in-picture=()',
|
||||||
|
'publickey-credentials-get=()',
|
||||||
|
'screen-wake-lock=()',
|
||||||
|
'sync-xhr=()',
|
||||||
|
'usb=()',
|
||||||
|
'web-share=()',
|
||||||
|
'xr-spatial-tracking=()',
|
||||||
|
].join(', '),
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn
|
||||||
|
// wirklich TLS davor läuft – sonst sperrt sich die App auf direkt-via-IP-
|
||||||
|
// Deployments (Browser versucht /assets/* via https zu laden → SSL-Error).
|
||||||
|
// Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy
|
||||||
|
// (Caddy/Traefik/Nginx) vor OpenCRM steht.
|
||||||
|
const httpsEnabled = process.env.HTTPS_ENABLED === 'true';
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
// CSP ausschalten – wird bei SPA schwierig, frontend setzt eigene CSP via meta
|
contentSecurityPolicy: {
|
||||||
contentSecurityPolicy: false,
|
useDefaults: true,
|
||||||
// Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin
|
directives: {
|
||||||
|
'default-src': ["'self'"],
|
||||||
|
'script-src': ["'self'"],
|
||||||
|
'style-src': ["'self'", "'unsafe-inline'"],
|
||||||
|
'img-src': ["'self'", 'data:', 'blob:'],
|
||||||
|
'font-src': ["'self'", 'data:'],
|
||||||
|
'connect-src': ["'self'"],
|
||||||
|
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
|
||||||
|
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
|
||||||
|
// 'none' würde sogar same-origin blocken und damit die UI brechen.
|
||||||
|
// Externe Sites bleiben weiterhin gesperrt.
|
||||||
|
'frame-ancestors': ["'self'"],
|
||||||
|
'object-src': ["'none'"],
|
||||||
|
'base-uri': ["'self'"],
|
||||||
|
'form-action': ["'self'"],
|
||||||
|
// useDefaults bringt 'upgrade-insecure-requests' selbst mit – explizit
|
||||||
|
// auf null setzen entfernt es aus dem Header (helmet-API).
|
||||||
|
'upgrade-insecure-requests': httpsEnabled ? [] : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// HSTS nur wenn echt TLS vorhanden – sonst sperrt sich der Browser
|
||||||
|
// dauerhaft aus, wenn die App direkt via http://ip:port erreichbar ist.
|
||||||
|
strictTransportSecurity: httpsEnabled
|
||||||
|
? { maxAge: 31536000, includeSubDomains: true }
|
||||||
|
: false,
|
||||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -101,12 +212,28 @@ app.use(express.json({ limit: '5mb' }));
|
|||||||
app.use(auditContextMiddleware);
|
app.use(auditContextMiddleware);
|
||||||
app.use(auditMiddleware);
|
app.use(auditMiddleware);
|
||||||
|
|
||||||
// Statische Dateien für Uploads – NUR für authentifizierte User.
|
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
||||||
// authenticate-Middleware unterstützt ?token=... Query-Parameter für direkte
|
// `/api/uploads/*` express.static).
|
||||||
// <a href>-Downloads, bei denen der Browser keinen Authorization-Header sendet.
|
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
|
||||||
// Ohne diesen Schutz könnte jeder per Datei-Name-Enumeration sensible PDFs
|
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
|
||||||
// (Ausweise, Kündigungsbestätigungen, Bankkarten) abrufen – DSGVO-GAU.
|
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
|
||||||
app.use('/api/uploads', authenticate as any, express.static(path.join(process.cwd(), 'uploads')));
|
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
|
||||||
|
//
|
||||||
|
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
|
||||||
|
// Request über denselben Owner-Check (kein freier static-Handler mehr).
|
||||||
|
|
||||||
|
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
|
||||||
|
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> – egal ob als
|
||||||
|
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
|
||||||
|
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
|
||||||
|
app.get('/api/files/download', authenticate as any, downloadFile as any);
|
||||||
|
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
|
||||||
|
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
|
||||||
|
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
||||||
|
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
|
||||||
|
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
|
||||||
|
return (downloadFile as any)(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
app.use('/api/public/consent', consentPublicRoutes);
|
||||||
@@ -141,6 +268,7 @@ app.use('/api/email-logs', emailLogRoutes);
|
|||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
app.use('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
|
app.use('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
@@ -189,4 +317,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => {
|
|||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
startBirthdayScheduler();
|
startBirthdayScheduler();
|
||||||
startContractStatusScheduler();
|
startContractStatusScheduler();
|
||||||
|
startSecurityMonitorScheduler();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
|
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
@@ -81,7 +82,16 @@ export async function authenticate(
|
|||||||
|
|
||||||
req.user = decoded;
|
req.user = decoded;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
|
||||||
|
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'TOKEN_REJECTED',
|
||||||
|
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
|
||||||
|
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
|
||||||
|
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
|
||||||
|
endpoint: `${req.method} ${req.path}`,
|
||||||
|
});
|
||||||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
||||||
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
||||||
|
*
|
||||||
|
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
|
||||||
|
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
||||||
|
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
||||||
*/
|
*/
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||||||
|
return (req: any, _res: any) => {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'RATE_LIMIT_HIT',
|
||||||
|
severity,
|
||||||
|
message: `Rate-Limit überschritten: ${label}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userEmail: req.body?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { limiter: label },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
* Login: 10 Versuche pro 15 Minuten pro IP.
|
||||||
@@ -19,6 +39,10 @@ export const loginRateLimiter = rateLimit({
|
|||||||
},
|
},
|
||||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
// Erfolgreiche Logins zählen nicht gegen das Limit
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('login', 'HIGH')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,4 +58,8 @@ export const passwordResetRateLimiter = rateLimit({
|
|||||||
success: false,
|
success: false,
|
||||||
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
||||||
},
|
},
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('password-reset', 'MEDIUM')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = Router();
|
|||||||
router.post('/login', loginRateLimiter, authController.login);
|
router.post('/login', loginRateLimiter, authController.login);
|
||||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
||||||
router.get('/me', authenticate, authController.me);
|
router.get('/me', authenticate, authController.me);
|
||||||
|
router.post('/logout', authenticate, authController.logout);
|
||||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
||||||
|
|
||||||
// Passwort-Reset-Flow
|
// Passwort-Reset-Flow
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
|
|||||||
// Follow-up contract
|
// Follow-up contract
|
||||||
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
|
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
|
||||||
|
|
||||||
|
// VVL (Vertragsverlängerung beim selben Anbieter, vollständige Kopie + Datums-Berechnung)
|
||||||
|
router.post('/:id/renewal', authenticate, requirePermission('contracts:create'), contractController.createRenewal);
|
||||||
|
|
||||||
// Snooze (Vertrag zurückstellen)
|
// Snooze (Vertrag zurückstellen)
|
||||||
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
|
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// In-Memory-Upload für die ZIP – wird direkt verarbeitet, keine temporäre Datei.
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ok =
|
||||||
|
file.mimetype === 'application/zip' ||
|
||||||
|
file.mimetype === 'application/x-zip-compressed' ||
|
||||||
|
file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip
|
||||||
|
file.originalname.toLowerCase().endsWith('.zip');
|
||||||
|
if (ok) cb(null, true);
|
||||||
|
else cb(new Error('Nur ZIP-Dateien sind erlaubt'));
|
||||||
|
},
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
// Preview (was wäre im Export drin?)
|
// Preview (was wäre im Export drin?)
|
||||||
router.get(
|
router.get(
|
||||||
'/preview',
|
'/preview',
|
||||||
@@ -20,4 +36,13 @@ router.get(
|
|||||||
factoryDefaultsController.exportFactoryDefaults,
|
factoryDefaultsController.exportFactoryDefaults,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Import aus ZIP (multipart, Feld 'zip')
|
||||||
|
router.post(
|
||||||
|
'/import',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:update'),
|
||||||
|
upload.single('zip'),
|
||||||
|
factoryDefaultsController.importFactoryDefaults,
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import * as monitoringController from '../controllers/monitoring.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
|
||||||
|
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
|
||||||
|
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
|
||||||
|
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
|
||||||
|
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
|
||||||
|
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
|
||||||
|
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -249,6 +249,7 @@ export async function createBackup(): Promise<BackupResult> {
|
|||||||
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
||||||
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
||||||
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
|
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
|
||||||
|
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalRecords = 0;
|
let totalRecords = 0;
|
||||||
@@ -310,6 +311,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
// Logs & Audit zuerst (hängen an allem)
|
// Logs & Audit zuerst (hängen an allem)
|
||||||
await prisma.auditLog.deleteMany({});
|
await prisma.auditLog.deleteMany({});
|
||||||
await prisma.emailLog.deleteMany({});
|
await prisma.emailLog.deleteMany({});
|
||||||
|
await prisma.securityEvent.deleteMany({});
|
||||||
|
|
||||||
// Detail-Tabellen
|
// Detail-Tabellen
|
||||||
await prisma.carInsuranceDetails.deleteMany({});
|
await prisma.carInsuranceDetails.deleteMany({});
|
||||||
@@ -887,6 +889,18 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'SecurityEvent',
|
||||||
|
restore: async (data: any[]) => {
|
||||||
|
for (const item of data) {
|
||||||
|
await prisma.securityEvent.upsert({
|
||||||
|
where: { id: item.id },
|
||||||
|
update: convertDates(item),
|
||||||
|
create: convertDates(item),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalRestored = 0;
|
let totalRestored = 0;
|
||||||
|
|||||||
@@ -765,6 +765,251 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||||||
return createContract(newContractData);
|
return createContract(newContractData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration.
|
||||||
|
* Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt
|
||||||
|
* sie auf 12 Monate als sicheren Default zurück.
|
||||||
|
*/
|
||||||
|
function durationToMonths(code: string | null | undefined, description: string | null | undefined): number {
|
||||||
|
const c = (code || '').trim();
|
||||||
|
const d = (description || '').trim();
|
||||||
|
let m = c.match(/^(\d+)\s*M$/i);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
m = c.match(/^(\d+)\s*J$/i);
|
||||||
|
if (m) return parseInt(m[1], 10) * 12;
|
||||||
|
m = d.match(/(\d+)\s*Monat/i);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
m = d.match(/(\d+)\s*Jahr/i);
|
||||||
|
if (m) return parseInt(m[1], 10) * 12;
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VVL = Vertragsverlängerung beim selben Anbieter.
|
||||||
|
*
|
||||||
|
* Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert:
|
||||||
|
* Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments.
|
||||||
|
*
|
||||||
|
* Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit.
|
||||||
|
* Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann
|
||||||
|
* der User es im Vertrag manuell anpassen.
|
||||||
|
*
|
||||||
|
* NICHT mitkopiert wird:
|
||||||
|
* - das Auftragsdokument (documentType "Auftragsformular") – das ist
|
||||||
|
* schließlich die NEU zu unterschreibende VVL.
|
||||||
|
* - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow,
|
||||||
|
* bei einer VVL nicht relevant)
|
||||||
|
*/
|
||||||
|
export async function createRenewalContract(previousContractId: number) {
|
||||||
|
const previousContract = await getContractById(previousContractId, true);
|
||||||
|
if (!previousContract) {
|
||||||
|
throw new Error('Vorgängervertrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits ein Folge-/VVL-Vertrag vorhanden?
|
||||||
|
const existing = await prisma.contract.findFirst({
|
||||||
|
where: { previousContractId },
|
||||||
|
select: { id: true, contractNumber: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Startdatum = altes Start + Laufzeit
|
||||||
|
let newStartDate: Date | null = null;
|
||||||
|
let newEndDate: Date | null = null;
|
||||||
|
if (previousContract.startDate && previousContract.contractDuration) {
|
||||||
|
const months = durationToMonths(
|
||||||
|
previousContract.contractDuration.code,
|
||||||
|
previousContract.contractDuration.description,
|
||||||
|
);
|
||||||
|
newStartDate = new Date(previousContract.startDate);
|
||||||
|
newStartDate.setMonth(newStartDate.getMonth() + months);
|
||||||
|
newEndDate = new Date(newStartDate);
|
||||||
|
newEndDate.setMonth(newEndDate.getMonth() + months);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder)
|
||||||
|
const contractNumber = generateContractNumber(previousContract.type);
|
||||||
|
|
||||||
|
const newContract = await prisma.contract.create({
|
||||||
|
data: {
|
||||||
|
contractNumber,
|
||||||
|
customerId: previousContract.customerId,
|
||||||
|
type: previousContract.type,
|
||||||
|
status: 'DRAFT',
|
||||||
|
contractCategoryId: previousContract.contractCategoryId,
|
||||||
|
addressId: previousContract.addressId,
|
||||||
|
billingAddressId: previousContract.billingAddressId,
|
||||||
|
bankCardId: previousContract.bankCardId,
|
||||||
|
identityDocumentId: previousContract.identityDocumentId,
|
||||||
|
salesPlatformId: previousContract.salesPlatformId,
|
||||||
|
cancellationPeriodId: previousContract.cancellationPeriodId,
|
||||||
|
contractDurationId: previousContract.contractDurationId,
|
||||||
|
previousContractId: previousContract.id,
|
||||||
|
previousProviderId: previousContract.previousProviderId,
|
||||||
|
providerId: previousContract.providerId,
|
||||||
|
tariffId: previousContract.tariffId,
|
||||||
|
providerName: previousContract.providerName,
|
||||||
|
tariffName: previousContract.tariffName,
|
||||||
|
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||||
|
portalUsername: previousContract.portalUsername,
|
||||||
|
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||||
|
commission: previousContract.commission,
|
||||||
|
notes: previousContract.notes,
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
// Cancellation-Felder bewusst leer lassen – die VVL hat den alten
|
||||||
|
// Cancel-Flow nicht geerbt.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu)
|
||||||
|
if (previousContract.energyDetails) {
|
||||||
|
const ed = previousContract.energyDetails;
|
||||||
|
const newEnergy = await prisma.energyContractDetails.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
meterId: ed.meterId,
|
||||||
|
maloId: ed.maloId,
|
||||||
|
annualConsumption: ed.annualConsumption,
|
||||||
|
annualConsumptionKwh: ed.annualConsumptionKwh,
|
||||||
|
basePrice: ed.basePrice,
|
||||||
|
unitPrice: ed.unitPrice,
|
||||||
|
unitPriceNt: ed.unitPriceNt,
|
||||||
|
bonus: ed.bonus,
|
||||||
|
previousProviderName: ed.previousProviderName,
|
||||||
|
previousCustomerNumber: ed.previousCustomerNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// ContractMeter-Verknüpfungen mitkopieren
|
||||||
|
for (const cm of ed.contractMeters || []) {
|
||||||
|
await prisma.contractMeter.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId: newEnergy.id,
|
||||||
|
meterId: cm.meterId,
|
||||||
|
position: cm.position,
|
||||||
|
installedAt: cm.installedAt,
|
||||||
|
removedAt: cm.removedAt,
|
||||||
|
finalReading: cm.finalReading,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previousContract.internetDetails) {
|
||||||
|
const id = previousContract.internetDetails;
|
||||||
|
const newInet = await prisma.internetContractDetails.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
downloadSpeed: id.downloadSpeed,
|
||||||
|
uploadSpeed: id.uploadSpeed,
|
||||||
|
routerModel: id.routerModel,
|
||||||
|
routerSerialNumber: id.routerSerialNumber,
|
||||||
|
installationDate: id.installationDate,
|
||||||
|
internetUsername: id.internetUsername,
|
||||||
|
internetPasswordEncrypted: id.internetPasswordEncrypted,
|
||||||
|
propertyType: id.propertyType,
|
||||||
|
propertyLocation: id.propertyLocation,
|
||||||
|
connectionLocation: id.connectionLocation,
|
||||||
|
homeId: id.homeId,
|
||||||
|
activationCode: id.activationCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const pn of id.phoneNumbers || []) {
|
||||||
|
await prisma.phoneNumber.create({
|
||||||
|
data: {
|
||||||
|
internetContractDetailsId: newInet.id,
|
||||||
|
phoneNumber: pn.phoneNumber,
|
||||||
|
isMain: pn.isMain,
|
||||||
|
sipUsername: pn.sipUsername,
|
||||||
|
sipPasswordEncrypted: pn.sipPasswordEncrypted,
|
||||||
|
sipServer: pn.sipServer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previousContract.mobileDetails) {
|
||||||
|
const md = previousContract.mobileDetails;
|
||||||
|
const newMob = await prisma.mobileContractDetails.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
requiresMultisim: md.requiresMultisim,
|
||||||
|
dataVolume: md.dataVolume,
|
||||||
|
includedMinutes: md.includedMinutes,
|
||||||
|
includedSMS: md.includedSMS,
|
||||||
|
deviceModel: md.deviceModel,
|
||||||
|
deviceImei: md.deviceImei,
|
||||||
|
phoneNumber: md.phoneNumber,
|
||||||
|
simCardNumber: md.simCardNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const sc of md.simCards || []) {
|
||||||
|
await prisma.simCard.create({
|
||||||
|
data: {
|
||||||
|
mobileDetailsId: newMob.id,
|
||||||
|
phoneNumber: sc.phoneNumber,
|
||||||
|
simCardNumber: sc.simCardNumber,
|
||||||
|
isMultisim: sc.isMultisim,
|
||||||
|
isMain: sc.isMain,
|
||||||
|
pin: sc.pin,
|
||||||
|
puk: sc.puk,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previousContract.tvDetails) {
|
||||||
|
await prisma.tvContractDetails.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
receiverModel: previousContract.tvDetails.receiverModel,
|
||||||
|
smartcardNumber: previousContract.tvDetails.smartcardNumber,
|
||||||
|
package: previousContract.tvDetails.package,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (previousContract.carInsuranceDetails) {
|
||||||
|
const ci = previousContract.carInsuranceDetails;
|
||||||
|
await prisma.carInsuranceDetails.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
licensePlate: ci.licensePlate,
|
||||||
|
hsn: ci.hsn,
|
||||||
|
tsn: ci.tsn,
|
||||||
|
vin: ci.vin,
|
||||||
|
vehicleType: ci.vehicleType,
|
||||||
|
firstRegistration: ci.firstRegistration,
|
||||||
|
noClaimsClass: ci.noClaimsClass,
|
||||||
|
insuranceType: ci.insuranceType,
|
||||||
|
deductiblePartial: ci.deductiblePartial,
|
||||||
|
deductibleFull: ci.deductibleFull,
|
||||||
|
previousInsurer: ci.previousInsurer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractDocuments mitkopieren – AUSSER "Auftragsformular" (das ist die
|
||||||
|
// neue Unterschrift, die der User selbst hochlädt). Files werden NICHT
|
||||||
|
// physisch dupliziert; beide Verträge zeigen auf dieselbe Datei.
|
||||||
|
const docs = await prisma.contractDocument.findMany({
|
||||||
|
where: { contractId: previousContract.id },
|
||||||
|
});
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.documentType.toLowerCase().includes('auftragsformular')) continue;
|
||||||
|
await prisma.contractDocument.create({
|
||||||
|
data: {
|
||||||
|
contractId: newContract.id,
|
||||||
|
documentType: d.documentType,
|
||||||
|
documentPath: d.documentPath,
|
||||||
|
originalName: d.originalName,
|
||||||
|
notes: d.notes,
|
||||||
|
uploadedBy: d.uploadedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.contract.findUnique({ where: { id: newContract.id } });
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt password for viewing
|
// Decrypt password for viewing
|
||||||
export async function getContractPassword(id: number): Promise<string | null> {
|
export async function getContractPassword(id: number): Promise<string | null> {
|
||||||
const contract = await prisma.contract.findUnique({
|
const contract = await prisma.contract.findUnique({
|
||||||
|
|||||||
@@ -129,3 +129,35 @@ export async function createNewContractFromPredecessorEntry(
|
|||||||
createdBy,
|
createdBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatischen Historie-Eintrag für VVL (Vertragsverlängerung) im Vorgängervertrag.
|
||||||
|
*/
|
||||||
|
export async function createRenewalHistoryEntry(
|
||||||
|
previousContractId: number,
|
||||||
|
newContractNumber: string,
|
||||||
|
createdBy: string
|
||||||
|
) {
|
||||||
|
return createHistoryEntry(previousContractId, {
|
||||||
|
title: `Vertragsverlängerung erstellt: ${newContractNumber}`,
|
||||||
|
description: `Eine Vertragsverlängerung (VVL) als ${newContractNumber} wurde aus diesem Vertrag erstellt – alle Daten wurden 1:1 übernommen, das Auftragsdokument muss neu hochgeladen werden.`,
|
||||||
|
isAutomatic: true,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatischen Historie-Eintrag im neuen VVL-Vertrag.
|
||||||
|
*/
|
||||||
|
export async function createNewRenewalFromPredecessorEntry(
|
||||||
|
newContractId: number,
|
||||||
|
previousContractNumber: string,
|
||||||
|
createdBy: string
|
||||||
|
) {
|
||||||
|
return createHistoryEntry(newContractId, {
|
||||||
|
title: `VVL zu ${previousContractNumber}`,
|
||||||
|
description: `Dieser Vertrag wurde als Vertragsverlängerung (VVL) zu ${previousContractNumber} erstellt.`,
|
||||||
|
isAutomatic: true,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
||||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen –
|
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails –
|
||||||
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||||
* Vertragskategorien und PDF-Auftragsvorlagen.
|
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
|
||||||
|
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
|
// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören.
|
||||||
|
// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte –
|
||||||
|
// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings.
|
||||||
|
export const FACTORY_DEFAULT_APP_SETTING_KEYS = [
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface AppSettingExport {
|
||||||
|
key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number];
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FactoryDefaultsManifest {
|
export interface FactoryDefaultsManifest {
|
||||||
version: 1;
|
version: 1;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
@@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest {
|
|||||||
contractDurations: number;
|
contractDurations: number;
|
||||||
contractCategories: number;
|
contractCategories: number;
|
||||||
pdfTemplates: number;
|
pdfTemplates: number;
|
||||||
|
appSettings: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +67,7 @@ export interface PdfTemplateExport {
|
|||||||
* Sammelt alle Katalog-Daten aus der DB.
|
* Sammelt alle Katalog-Daten aus der DB.
|
||||||
*/
|
*/
|
||||||
export async function collectFactoryDefaults() {
|
export async function collectFactoryDefaults() {
|
||||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
|
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.provider.findMany({
|
prisma.provider.findMany({
|
||||||
include: { tariffs: { select: { name: true, isActive: true } } },
|
include: { tariffs: { select: { name: true, isActive: true } } },
|
||||||
@@ -59,6 +77,11 @@ export async function collectFactoryDefaults() {
|
|||||||
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
||||||
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||||
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.appSetting.findMany({
|
||||||
|
where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
orderBy: { key: 'asc' },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +131,7 @@ export async function collectFactoryDefaults() {
|
|||||||
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
appSettings: appSettings as AppSettingExport[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +156,7 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
|
appSettings: data.appSettings.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +185,9 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
||||||
name: 'pdf-templates/pdf-templates.json',
|
name: 'pdf-templates/pdf-templates.json',
|
||||||
});
|
});
|
||||||
|
archive.append(JSON.stringify(data.appSettings, null, 2), {
|
||||||
|
name: 'app-settings/app-settings.json',
|
||||||
|
});
|
||||||
|
|
||||||
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
||||||
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||||
@@ -192,3 +220,244 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// IMPORT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface FactoryDefaultsImportResult {
|
||||||
|
providers: number;
|
||||||
|
tariffs: number;
|
||||||
|
cancellationPeriods: number;
|
||||||
|
contractDurations: number;
|
||||||
|
contractCategories: number;
|
||||||
|
pdfTemplates: number;
|
||||||
|
pdfTemplatesSkipped: number;
|
||||||
|
appSettings: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonEntry<T>(zip: AdmZip, name: string): T[] {
|
||||||
|
const entry = zip.getEntry(name);
|
||||||
|
if (!entry) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(entry.getData().toString('utf-8'));
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet ein Factory-Defaults-ZIP idempotent auf die DB an.
|
||||||
|
* - upsert über unique-Keys: nichts wird gelöscht
|
||||||
|
* - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix
|
||||||
|
* - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS)
|
||||||
|
*
|
||||||
|
* Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu
|
||||||
|
* (`pdf-templates/<basename>`), niemals auf einen aus dem ZIP konstruierten
|
||||||
|
* Pfad im Filesystem.
|
||||||
|
*/
|
||||||
|
export async function importFactoryDefaults(
|
||||||
|
zipBuffer: Buffer,
|
||||||
|
): Promise<FactoryDefaultsImportResult> {
|
||||||
|
const zip = new AdmZip(zipBuffer);
|
||||||
|
const result: FactoryDefaultsImportResult = {
|
||||||
|
providers: 0,
|
||||||
|
tariffs: 0,
|
||||||
|
cancellationPeriods: 0,
|
||||||
|
contractDurations: 0,
|
||||||
|
contractCategories: 0,
|
||||||
|
pdfTemplates: 0,
|
||||||
|
pdfTemplatesSkipped: 0,
|
||||||
|
appSettings: 0,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Providers + Tariffs
|
||||||
|
const providers = parseJsonEntry<ProviderExport>(zip, 'providers/providers.json');
|
||||||
|
for (const p of providers) {
|
||||||
|
if (!p.name) continue;
|
||||||
|
const provider = await prisma.provider.upsert({
|
||||||
|
where: { name: p.name },
|
||||||
|
update: {
|
||||||
|
portalUrl: p.portalUrl ?? null,
|
||||||
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
|
passwordFieldName: p.passwordFieldName ?? null,
|
||||||
|
isActive: p.isActive ?? true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: p.name,
|
||||||
|
portalUrl: p.portalUrl ?? null,
|
||||||
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
|
passwordFieldName: p.passwordFieldName ?? null,
|
||||||
|
isActive: p.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.providers++;
|
||||||
|
for (const t of p.tariffs ?? []) {
|
||||||
|
if (!t.name) continue;
|
||||||
|
await prisma.tariff.upsert({
|
||||||
|
where: { providerId_name: { providerId: provider.id, name: t.name } },
|
||||||
|
update: { isActive: t.isActive ?? true },
|
||||||
|
create: { providerId: provider.id, name: t.name, isActive: t.isActive ?? true },
|
||||||
|
});
|
||||||
|
result.tariffs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contract-Meta
|
||||||
|
const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
||||||
|
zip,
|
||||||
|
'contract-meta/cancellation-periods.json',
|
||||||
|
);
|
||||||
|
for (const c of cancellationPeriods) {
|
||||||
|
if (!c.code || !c.description) continue;
|
||||||
|
await prisma.cancellationPeriod.upsert({
|
||||||
|
where: { code: c.code },
|
||||||
|
update: { description: c.description, isActive: c.isActive ?? true },
|
||||||
|
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
|
||||||
|
});
|
||||||
|
result.cancellationPeriods++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
||||||
|
zip,
|
||||||
|
'contract-meta/contract-durations.json',
|
||||||
|
);
|
||||||
|
for (const d of contractDurations) {
|
||||||
|
if (!d.code || !d.description) continue;
|
||||||
|
await prisma.contractDuration.upsert({
|
||||||
|
where: { code: d.code },
|
||||||
|
update: { description: d.description, isActive: d.isActive ?? true },
|
||||||
|
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
|
||||||
|
});
|
||||||
|
result.contractDurations++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractCategories = parseJsonEntry<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}>(zip, 'contract-meta/contract-categories.json');
|
||||||
|
for (const c of contractCategories) {
|
||||||
|
if (!c.code || !c.name) continue;
|
||||||
|
await prisma.contractCategory.upsert({
|
||||||
|
where: { code: c.code },
|
||||||
|
update: {
|
||||||
|
name: c.name,
|
||||||
|
icon: c.icon ?? null,
|
||||||
|
color: c.color ?? null,
|
||||||
|
sortOrder: c.sortOrder ?? 0,
|
||||||
|
isActive: c.isActive ?? true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
code: c.code,
|
||||||
|
name: c.name,
|
||||||
|
icon: c.icon ?? null,
|
||||||
|
color: c.color ?? null,
|
||||||
|
sortOrder: c.sortOrder ?? 0,
|
||||||
|
isActive: c.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.contractCategories++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PDF-Vorlagen (JSON + binär aus dem ZIP)
|
||||||
|
const pdfTemplates = parseJsonEntry<PdfTemplateExport>(
|
||||||
|
zip,
|
||||||
|
'pdf-templates/pdf-templates.json',
|
||||||
|
);
|
||||||
|
if (pdfTemplates.length > 0) {
|
||||||
|
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||||
|
const pdfDestDir = path.join(uploadsRoot, 'pdf-templates');
|
||||||
|
if (!fs.existsSync(pdfDestDir)) {
|
||||||
|
fs.mkdirSync(pdfDestDir, { recursive: true });
|
||||||
|
}
|
||||||
|
for (const t of pdfTemplates) {
|
||||||
|
if (!t.name || !t.pdfFilename) continue;
|
||||||
|
// Anti-Zip-Slip: nur basename verwenden, kein Pfad
|
||||||
|
const basename = path.basename(t.pdfFilename);
|
||||||
|
const entry = zip.getEntry(`pdf-templates/${basename}`);
|
||||||
|
if (!entry) {
|
||||||
|
result.pdfTemplatesSkipped++;
|
||||||
|
result.warnings.push(`PDF fehlt im ZIP: ${basename} – Vorlage "${t.name}" übersprungen`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(t.originalName || basename) || '.pdf';
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`;
|
||||||
|
const destPdf = path.join(pdfDestDir, destFilename);
|
||||||
|
const relativePath = `/uploads/pdf-templates/${destFilename}`;
|
||||||
|
|
||||||
|
fs.writeFileSync(destPdf, entry.getData());
|
||||||
|
|
||||||
|
// Bei existierender Vorlage die alte Datei aufräumen
|
||||||
|
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
|
||||||
|
if (existing?.templatePath) {
|
||||||
|
const oldRel = existing.templatePath.startsWith('/uploads/')
|
||||||
|
? existing.templatePath.substring('/uploads/'.length)
|
||||||
|
: existing.templatePath;
|
||||||
|
const oldAbs = path.join(uploadsRoot, oldRel);
|
||||||
|
if (fs.existsSync(oldAbs)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(oldAbs);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {});
|
||||||
|
await prisma.pdfTemplate.upsert({
|
||||||
|
where: { name: t.name },
|
||||||
|
update: {
|
||||||
|
description: t.description ?? null,
|
||||||
|
providerName: t.providerName ?? null,
|
||||||
|
templatePath: relativePath,
|
||||||
|
originalName: t.originalName,
|
||||||
|
fieldMapping: fieldMappingJson,
|
||||||
|
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
||||||
|
maxPhoneFields: t.maxPhoneFields ?? 8,
|
||||||
|
isActive: t.isActive ?? true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description ?? null,
|
||||||
|
providerName: t.providerName ?? null,
|
||||||
|
templatePath: relativePath,
|
||||||
|
originalName: t.originalName,
|
||||||
|
fieldMapping: fieldMappingJson,
|
||||||
|
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
||||||
|
maxPhoneFields: t.maxPhoneFields ?? 8,
|
||||||
|
isActive: t.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.pdfTemplates++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AppSettings (HTML-Templates, Whitelist)
|
||||||
|
const appSettings = parseJsonEntry<AppSettingExport>(zip, 'app-settings/app-settings.json');
|
||||||
|
const allowedKeys = new Set<string>(FACTORY_DEFAULT_APP_SETTING_KEYS);
|
||||||
|
for (const s of appSettings) {
|
||||||
|
if (!s.key || typeof s.value !== 'string') continue;
|
||||||
|
if (!allowedKeys.has(s.key)) {
|
||||||
|
result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist – ignoriert`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: { value: s.value },
|
||||||
|
create: { key: s.key, value: s.value },
|
||||||
|
});
|
||||||
|
result.appSettings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Pfad → Resource → Owner Mapping für `/api/files/download`.
|
||||||
|
*
|
||||||
|
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
|
||||||
|
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
|
||||||
|
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
|
||||||
|
* canAccessContract entscheidet danach über Zugriff.
|
||||||
|
*
|
||||||
|
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
|
||||||
|
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
|
||||||
|
* automatisch ausgeschlossen – ein konstruierter Pfad findet keinen Record.
|
||||||
|
*/
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
|
export type FileOwner =
|
||||||
|
| { kind: 'customer'; customerId: number }
|
||||||
|
| { kind: 'contract'; contractId: number }
|
||||||
|
| { kind: 'admin' }
|
||||||
|
| { kind: 'gdpr-admin' };
|
||||||
|
|
||||||
|
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
|
||||||
|
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
|
||||||
|
if (!uploadPath.startsWith('/uploads/')) return null;
|
||||||
|
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
|
||||||
|
|
||||||
|
const parts = uploadPath.split('/');
|
||||||
|
// ['', 'uploads', '<subDir>', '<filename...>']
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
const subDir = parts[2];
|
||||||
|
|
||||||
|
switch (subDir) {
|
||||||
|
case 'bank-cards': {
|
||||||
|
const r = await prisma.bankCard.findFirst({
|
||||||
|
where: { documentPath: uploadPath },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'documents': {
|
||||||
|
const r = await prisma.identityDocument.findFirst({
|
||||||
|
where: { documentPath: uploadPath },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'business-registrations': {
|
||||||
|
const r = await prisma.customer.findFirst({
|
||||||
|
where: { businessRegistrationPath: uploadPath },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'commercial-registers': {
|
||||||
|
const r = await prisma.customer.findFirst({
|
||||||
|
where: { commercialRegisterPath: uploadPath },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'privacy-policies': {
|
||||||
|
const r = await prisma.customer.findFirst({
|
||||||
|
where: { privacyPolicyPath: uploadPath },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'authorizations': {
|
||||||
|
const r = await prisma.representativeAuthorization.findFirst({
|
||||||
|
where: { documentPath: uploadPath },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'contract-documents': {
|
||||||
|
const r = await prisma.contractDocument.findFirst({
|
||||||
|
where: { documentPath: uploadPath },
|
||||||
|
select: { contractId: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'contract', contractId: r.contractId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'invoices': {
|
||||||
|
const r = await prisma.invoice.findFirst({
|
||||||
|
where: { documentPath: uploadPath },
|
||||||
|
select: { contractId: true },
|
||||||
|
});
|
||||||
|
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancellation-letters':
|
||||||
|
case 'cancellation-confirmations':
|
||||||
|
case 'cancellation-letters-options':
|
||||||
|
case 'cancellation-confirmations-options': {
|
||||||
|
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
|
||||||
|
'cancellation-letters': 'cancellationLetterPath',
|
||||||
|
'cancellation-confirmations': 'cancellationConfirmationPath',
|
||||||
|
'cancellation-letters-options': 'cancellationLetterOptionsPath',
|
||||||
|
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
|
||||||
|
};
|
||||||
|
const field = fieldMap[subDir];
|
||||||
|
const r = await prisma.contract.findFirst({
|
||||||
|
where: { [field]: uploadPath },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'contract', contractId: r.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pdf-templates': {
|
||||||
|
// Admin-only Resource: Vorlagen gehören keinem Customer.
|
||||||
|
const r = await prisma.pdfTemplate.findFirst({
|
||||||
|
where: { templatePath: uploadPath },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return r ? { kind: 'admin' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export interface ImapCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||||
|
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||||
|
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||||
|
servername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +32,12 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
|||||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
const options: Record<string, unknown> = { rejectUnauthorized };
|
||||||
|
|
||||||
|
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
|
||||||
|
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
|
||||||
|
if (credentials.servername) {
|
||||||
|
options.servername = credentials.servername;
|
||||||
|
}
|
||||||
|
|
||||||
if (credentials.allowSelfSignedCerts) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
options.minVersion = 'TLSv1';
|
options.minVersion = 'TLSv1';
|
||||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Security-Alerting:
|
||||||
|
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
|
||||||
|
* Cron alle 60s gepollt) – z.B. Threshold-Überschreitungen.
|
||||||
|
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
|
||||||
|
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
|
||||||
|
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
|
||||||
|
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
|
||||||
|
* Events wenn die Schwelle erreicht ist.
|
||||||
|
*
|
||||||
|
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
|
||||||
|
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
|
||||||
|
*/
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||||
|
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||||
|
import * as appSettingService from './appSetting.service.js';
|
||||||
|
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||||
|
import type { SecurityEvent } from '@prisma/client';
|
||||||
|
|
||||||
|
interface AlertEmailParams {
|
||||||
|
subject: string;
|
||||||
|
events: SecurityEvent[];
|
||||||
|
isDigest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityIcon(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'CRITICAL': return '🚨';
|
||||||
|
case 'HIGH': return '⚠️';
|
||||||
|
case 'MEDIUM': return '🟡';
|
||||||
|
case 'LOW': return '🟢';
|
||||||
|
default: return 'ℹ️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToHtmlRow(e: SecurityEvent): string {
|
||||||
|
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
|
||||||
|
const ip = e.ipAddress || '–';
|
||||||
|
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–');
|
||||||
|
const ep = e.endpoint || '–';
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
|
||||||
|
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
|
||||||
|
<td style="padding:4px 8px">${e.type}</td>
|
||||||
|
<td style="padding:4px 8px">${e.message}</td>
|
||||||
|
<td style="padding:4px 8px">${who}</td>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
|
||||||
|
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHtmlEmail(params: AlertEmailParams): string {
|
||||||
|
const rows = params.events.map(eventToHtmlRow).join('\n');
|
||||||
|
const heading = params.isDigest
|
||||||
|
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
|
||||||
|
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
|
||||||
|
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
|
||||||
|
${heading}
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
|
||||||
|
<thead style="background:#f3f4f6">
|
||||||
|
<tr>
|
||||||
|
<th align="left" style="padding:6px 8px">Zeit</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Severity</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Typ</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Nachricht</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Wer</th>
|
||||||
|
<th align="left" style="padding:6px 8px">IP</th>
|
||||||
|
<th align="left" style="padding:6px 8px">Endpoint</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
|
||||||
|
Konfiguration: Einstellungen → Monitoring.</p>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
|
||||||
|
*/
|
||||||
|
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
|
||||||
|
const sysEmail = await getSystemEmailCredentials();
|
||||||
|
if (!sysEmail) {
|
||||||
|
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials: SmtpCredentials = {
|
||||||
|
host: sysEmail.smtpServer,
|
||||||
|
port: sysEmail.smtpPort,
|
||||||
|
user: sysEmail.emailAddress,
|
||||||
|
password: sysEmail.password,
|
||||||
|
encryption: sysEmail.smtpEncryption,
|
||||||
|
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendEmail(
|
||||||
|
credentials,
|
||||||
|
sysEmail.emailAddress,
|
||||||
|
{
|
||||||
|
to: toAddress,
|
||||||
|
subject: params.subject,
|
||||||
|
html: buildHtmlEmail(params),
|
||||||
|
},
|
||||||
|
{ context: 'security-alert', triggeredBy: 'monitor' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
? { success: true }
|
||||||
|
: { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
|
||||||
|
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
|
||||||
|
*
|
||||||
|
* Regeln (alle pro IP):
|
||||||
|
* - >= 10 LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht
|
||||||
|
* - >= 5 ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht
|
||||||
|
* - >= 3 SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing
|
||||||
|
* - >= 3 TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
|
||||||
|
*/
|
||||||
|
export async function detectThresholds(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
|
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
windowStart: Date;
|
||||||
|
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
|
||||||
|
threshold: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
const buckets: Bucket[] = [
|
||||||
|
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
|
||||||
|
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
|
||||||
|
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
|
||||||
|
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const b of buckets) {
|
||||||
|
const grouped = await prisma.securityEvent.groupBy({
|
||||||
|
by: ['ipAddress'],
|
||||||
|
where: {
|
||||||
|
type: b.type as any,
|
||||||
|
createdAt: { gte: b.windowStart },
|
||||||
|
},
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
for (const g of grouped) {
|
||||||
|
if ((g._count as number) < b.threshold) continue;
|
||||||
|
// Debounce: pro IP max. 1 SUSPICIOUS-Alert pro 60min (sliding window).
|
||||||
|
// Vorher: floor(now, hour) → resettete bei Stundenwechsel und produzierte
|
||||||
|
// doppelte Alerts (Bug aus Runde 10).
|
||||||
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
const existing = await prisma.securityEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
ipAddress: g.ipAddress,
|
||||||
|
createdAt: { gte: oneHourAgo },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await emitSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
|
||||||
|
ipAddress: g.ipAddress,
|
||||||
|
details: { rule: b.type, count: g._count, threshold: b.threshold },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
|
||||||
|
* 1 Mail pro IP pro Stunde, damit nicht spammend).
|
||||||
|
*/
|
||||||
|
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) return { sent: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const pending = await prisma.securityEvent.findMany({
|
||||||
|
where: { severity: 'CRITICAL', alerted: false },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const ev of pending) {
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
|
||||||
|
events: [ev],
|
||||||
|
isDigest: false,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
sent++;
|
||||||
|
await prisma.securityEvent.update({
|
||||||
|
where: { id: ev.id },
|
||||||
|
data: { alerted: true, alertedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sent, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
|
||||||
|
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
|
||||||
|
*/
|
||||||
|
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
|
||||||
|
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
||||||
|
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
|
||||||
|
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
||||||
|
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
|
||||||
|
|
||||||
|
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
|
||||||
|
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const events = await prisma.securityEvent.findMany({
|
||||||
|
where: {
|
||||||
|
severity: { in: ['HIGH', 'MEDIUM'] },
|
||||||
|
alerted: false,
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||||
|
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendAlertEmail(alertEmail, {
|
||||||
|
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
|
||||||
|
events,
|
||||||
|
isDigest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await prisma.securityEvent.updateMany({
|
||||||
|
where: { id: { in: events.map((e) => e.id) } },
|
||||||
|
data: { alerted: true, alertedAt: new Date() },
|
||||||
|
});
|
||||||
|
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
||||||
|
return { sent: true, eventCount: events.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent: false, eventCount: events.length, reason: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-Scheduler:
|
||||||
|
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
|
||||||
|
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
|
||||||
|
*/
|
||||||
|
export function startSecurityMonitorScheduler(): void {
|
||||||
|
cron.schedule('* * * * *', async () => {
|
||||||
|
try {
|
||||||
|
await detectThresholds();
|
||||||
|
await sendPendingCriticalAlerts();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityAlert] minute-cron failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cron.schedule('0 * * * *', async () => {
|
||||||
|
try {
|
||||||
|
await sendDigest();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityAlert] hourly-digest failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
|
||||||
|
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
|
||||||
|
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
|
||||||
|
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
|
||||||
|
*
|
||||||
|
* Hooks für die wichtigsten Klassen:
|
||||||
|
* - LOGIN_FAILED → Login mit falschem Passwort
|
||||||
|
* - LOGIN_SUCCESS → erfolgreicher Login (informativ)
|
||||||
|
* - RATE_LIMIT_HIT → express-rate-limit hat zugeschlagen
|
||||||
|
* - ACCESS_DENIED → 403 von canAccess* (versuchter IDOR)
|
||||||
|
* - SSRF_BLOCKED → ssrfGuard hat geblockt
|
||||||
|
* - PASSWORD_RESET_REQUEST → Reset angefordert
|
||||||
|
* - PASSWORD_RESET_CONFIRM → Reset abgeschlossen
|
||||||
|
* - LOGOUT → expliziter Logout
|
||||||
|
* - TOKEN_REJECTED → JWT verify-Failure
|
||||||
|
* - PERMISSION_CHANGED → Rolle/Permission-Update
|
||||||
|
*
|
||||||
|
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
|
||||||
|
* konfiguriert), sonst Sammlung im stündlichen Digest.
|
||||||
|
*/
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface SecurityEventInput {
|
||||||
|
type: SecurityEventType;
|
||||||
|
severity: SecuritySeverity;
|
||||||
|
message: string;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userId?: number | null;
|
||||||
|
customerId?: number | null;
|
||||||
|
userEmail?: string | null;
|
||||||
|
endpoint?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
|
||||||
|
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
|
||||||
|
*/
|
||||||
|
export async function emit(event: SecurityEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.securityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: event.type,
|
||||||
|
severity: event.severity,
|
||||||
|
message: event.message,
|
||||||
|
ipAddress: event.ipAddress || null,
|
||||||
|
userId: event.userId || null,
|
||||||
|
customerId: event.customerId || null,
|
||||||
|
userEmail: event.userEmail || null,
|
||||||
|
endpoint: event.endpoint || null,
|
||||||
|
details: event.details ? (event.details as any) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[securityMonitor] emit failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
|
||||||
|
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
|
||||||
|
* Requests (Login-Versuch etc.).
|
||||||
|
*/
|
||||||
|
export function contextFromRequest(req: any): {
|
||||||
|
ipAddress: string;
|
||||||
|
userId?: number;
|
||||||
|
customerId?: number;
|
||||||
|
userEmail?: string;
|
||||||
|
endpoint: string;
|
||||||
|
} {
|
||||||
|
const user = req?.user;
|
||||||
|
return {
|
||||||
|
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
|
||||||
|
userId: user?.userId,
|
||||||
|
customerId: user?.customerId,
|
||||||
|
userEmail: user?.email,
|
||||||
|
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ export interface SmtpCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||||
|
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||||
|
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||||
|
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
|
||||||
|
servername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anhang-Interface
|
// Anhang-Interface
|
||||||
@@ -94,7 +98,7 @@ export async function sendEmail(
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
requireTLS?: boolean;
|
requireTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
@@ -116,6 +120,11 @@ export async function sendEmail(
|
|||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
|
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
|
||||||
|
// Hostname für SNI/Cert-Validation explizit setzen.
|
||||||
|
if (credentials.servername) {
|
||||||
|
transportOptions.tls.servername = credentials.servername;
|
||||||
|
}
|
||||||
if (credentials.allowSelfSignedCerts) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||||
transportOptions.tls.minVersion = 'TLSv1';
|
transportOptions.tls.minVersion = 'TLSv1';
|
||||||
@@ -303,7 +312,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
greetingTimeout: number;
|
greetingTimeout: number;
|
||||||
@@ -321,6 +330,9 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
|
if (credentials.servername) {
|
||||||
|
transportOptions.tls.servername = credentials.servername;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,26 @@ import { Response } from 'express';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
|
||||||
|
* Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection.
|
||||||
|
*/
|
||||||
|
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'ACCESS_DENIED',
|
||||||
|
severity: 'MEDIUM',
|
||||||
|
message: `Zugriff verweigert: ${label} #${targetId}`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: ctx.userId,
|
||||||
|
customerId: ctx.customerId,
|
||||||
|
userEmail: ctx.userEmail,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { resource: label, targetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
||||||
@@ -54,6 +74,7 @@ export async function canAccessContract(
|
|||||||
// Fremde Verträge nur mit aktiver Vollmacht
|
// Fremde Verträge nur mit aktiver Vollmacht
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
if (!representedIds.includes(contract.customerId)) {
|
if (!representedIds.includes(contract.customerId)) {
|
||||||
|
emitAccessDenied(req, 'Contract', contractId);
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -63,6 +84,7 @@ export async function canAccessContract(
|
|||||||
req.user.customerId,
|
req.user.customerId,
|
||||||
);
|
);
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
|
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -93,12 +115,14 @@ export async function canAccessCustomer(
|
|||||||
|
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||||
if (!representedIds.includes(customerId)) {
|
if (!representedIds.includes(customerId)) {
|
||||||
|
emitAccessDenied(req, 'Customer', customerId);
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
|
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
'signalNumber',
|
'signalNumber',
|
||||||
'roleIds',
|
'roleIds',
|
||||||
'password', // nur Admin, wird im Service gehashed
|
'password', // nur Admin, wird im Service gehashed
|
||||||
|
// hasGdprAccess + hasDeveloperAccess sind keine User-Spalten – der Service
|
||||||
|
// mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
|
||||||
|
// setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
|
||||||
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
|
'hasGdprAccess',
|
||||||
|
'hasDeveloperAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
|
||||||
|
* Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
|
||||||
|
*
|
||||||
|
* Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
|
||||||
|
* 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
|
||||||
|
* Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
|
||||||
|
* blockieren wir nur:
|
||||||
|
* - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
|
||||||
|
* - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
|
||||||
|
* - 0.0.0.0/8 (ungültiger Source/Routing-Range)
|
||||||
|
* - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
|
||||||
|
*
|
||||||
|
* Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
|
||||||
|
* Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es
|
||||||
|
* legitimes Caching/CDN-Verhalten brechen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLOCKED_PATTERNS: RegExp[] = [
|
||||||
|
/^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA)
|
||||||
|
/^0\./, // 0.0.0.0/8 reserved
|
||||||
|
/^22[4-9]\./, // 224-229 Multicast
|
||||||
|
/^23[0-9]\./, // 230-239 Multicast
|
||||||
|
/^24[0-9]\./, // 240-249 reserved
|
||||||
|
/^25[0-5]\./, // 250-255 reserved
|
||||||
|
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
||||||
|
/^fe80:/i, // IPv6 Link-Local
|
||||||
|
/^ff/i, // IPv6 Multicast
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKED_HOSTNAMES = new Set([
|
||||||
|
'metadata.google.internal',
|
||||||
|
'metadata.goog',
|
||||||
|
'metadata',
|
||||||
|
'169.254.169.254',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||||
|
if (!host) return false;
|
||||||
|
const h = host.trim().toLowerCase();
|
||||||
|
if (!h) return false;
|
||||||
|
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(h)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
||||||
|
* Caller sollte den Fehler in 400er Response umsetzen.
|
||||||
|
*/
|
||||||
|
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
|
||||||
|
if (isBlockedSsrfHost(host)) {
|
||||||
|
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { promises as dns } from 'dns';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
|
||||||
|
* gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
|
||||||
|
*
|
||||||
|
* Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
|
||||||
|
* als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
|
||||||
|
* Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
|
||||||
|
* ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
|
||||||
|
*
|
||||||
|
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
|
||||||
|
*/
|
||||||
|
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
|
||||||
|
if (!host || !host.trim()) {
|
||||||
|
throw new Error(`${label} fehlt`);
|
||||||
|
}
|
||||||
|
const trimmed = host.trim();
|
||||||
|
|
||||||
|
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||||
|
if (net.isIP(trimmed)) {
|
||||||
|
assertAllowedHost(trimmed, label);
|
||||||
|
return { ip: trimmed, servername: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hostname → resolve to IPv4 + IPv6
|
||||||
|
let ips: string[] = [];
|
||||||
|
try {
|
||||||
|
const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
|
||||||
|
const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
|
||||||
|
ips = [...v4, ...v6];
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ips.length === 0) {
|
||||||
|
throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (isBlockedSsrfHost(ip)) {
|
||||||
|
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ip: ips[0], servername: trimmed };
|
||||||
|
}
|
||||||
+96
-10
@@ -1,4 +1,19 @@
|
|||||||
version: '3.8'
|
# OpenCRM – komplettes Setup: MariaDB + Backend/Frontend + Adminer
|
||||||
|
# Konfiguration über ./.env (siehe ./.env.example)
|
||||||
|
#
|
||||||
|
# Quick-Start (Compose v2):
|
||||||
|
# cp .env.example .env # Werte anpassen (Secrets rotieren!)
|
||||||
|
# docker compose up -d # erstes Mal: holt Images, baut Backend, startet alles
|
||||||
|
# Quick-Start (Compose v1, Legacy):
|
||||||
|
# docker-compose up -d
|
||||||
|
#
|
||||||
|
# Browser:
|
||||||
|
# http://localhost:${OPENCRM_PORT} # CRM
|
||||||
|
# http://localhost:${ADMINER_PORT} # DB-UI
|
||||||
|
#
|
||||||
|
# Daten liegen alle unter ./data/* – Bind-Mounts statt Volumes (auf Wunsch).
|
||||||
|
|
||||||
|
#version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
@@ -6,20 +21,91 @@ services:
|
|||||||
container_name: opencrm-db
|
container_name: opencrm-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: rootpassword
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
MYSQL_DATABASE: opencrm
|
MARIADB_DATABASE: ${DB_NAME}
|
||||||
MYSQL_USER: opencrm
|
MARIADB_USER: ${DB_USER}
|
||||||
MYSQL_PASSWORD: opencrm123
|
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
# Externe Erreichbarkeit für lokale DB-Tools (TablePlus etc.).
|
||||||
|
# Auf 127.0.0.1 binden – kein public exposure.
|
||||||
|
- "127.0.0.1:${DB_PORT:-3306}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mariadb_data:/var/lib/mysql
|
- ${DB_DATA_DIR:-./data/db}:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
start_period: 10s
|
start_period: 20s
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|
||||||
|
opencrm:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
container_name: opencrm-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# DATABASE_URL wird vom entrypoint.sh aus den DB_*-Komponenten gebaut –
|
||||||
|
# mit encodeURIComponent für Passwörter mit Sonderzeichen ($, !, #, @, :,
|
||||||
|
# / etc.). KEIN root für die App, sondern der App-User ${DB_USER}, den
|
||||||
|
# MariaDB beim ersten Start automatisch mit GRANT ALL PRIVILEGES auf
|
||||||
|
# ${DB_NAME}.* anlegt (über MARIADB_USER/MARIADB_PASSWORD).
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3001
|
||||||
|
LISTEN_ADDR: 0.0.0.0
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
||||||
|
HTTPS_ENABLED: ${HTTPS_ENABLED:-false}
|
||||||
|
RUN_SEED: ${RUN_SEED:-false}
|
||||||
|
ports:
|
||||||
|
- "${OPENCRM_PORT:-3010}:3001"
|
||||||
volumes:
|
volumes:
|
||||||
mariadb_data:
|
# Bind-Mounts für persistente Daten unter ./data/
|
||||||
|
- ${UPLOADS_DIR:-./data/uploads}:/app/uploads
|
||||||
|
- ${FACTORY_DEFAULTS_DIR:-./data/factory-defaults}:/app/factory-defaults
|
||||||
|
- ${BACKUPS_DIR:-./data/backups}:/app/prisma/backups
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: opencrm-adminer
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
ADMINER_DEFAULT_SERVER: db
|
||||||
|
ADMINER_DESIGN: ${ADMINER_DESIGN:-pepa-linha}
|
||||||
|
# Adminers offizieller entrypoint linkt nur Designs, deren CSS exakt
|
||||||
|
# `adminer.css` heißt. Manche Designs (dracula, adminer-dark) haben aber
|
||||||
|
# `adminer-dark.css`. Wir machen den Symlink generisch: erstes .css im
|
||||||
|
# gewählten Design wird verlinkt. Danach übergeben wir an den originalen
|
||||||
|
# entrypoint.sh.
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- >
|
||||||
|
cd /var/www/html;
|
||||||
|
if [ -n "$$ADMINER_DESIGN" ] && [ -d "designs/$$ADMINER_DESIGN" ]; then
|
||||||
|
CSS=$$(ls designs/$$ADMINER_DESIGN/*.css 2>/dev/null | head -1);
|
||||||
|
if [ -n "$$CSS" ]; then
|
||||||
|
ln -sf "$$CSS" adminer.css;
|
||||||
|
touch .adminer-init;
|
||||||
|
echo "[adminer-bootstrap] Theme aktiv: $$ADMINER_DESIGN -> $$CSS";
|
||||||
|
else
|
||||||
|
echo "[adminer-bootstrap] Design '$$ADMINER_DESIGN' enthält kein CSS – nutze Default";
|
||||||
|
fi;
|
||||||
|
fi;
|
||||||
|
exec entrypoint.sh docker-php-entrypoint "$$@"
|
||||||
|
- --
|
||||||
|
command: ["php", "-S", "[::]:8080", "-t", "/var/www/html"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ADMINER_PORT:-8090}:8080"
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# 🛡️ Security-Hardening – die ganze Geschichte
|
||||||
|
|
||||||
|
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
|
||||||
|
Code-Review und öffentlichem Deployment durchlaufen hat.
|
||||||
|
|
||||||
|
Format pro Runde: **Was war kaputt** → **Wie es gefixt wurde** → wo möglich
|
||||||
|
**Live-Test-Resultate**.
|
||||||
|
|
||||||
|
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
|
||||||
|
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Live-verifizierte Tests im Überblick
|
||||||
|
|
||||||
|
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
|
||||||
|
durchgespielt – statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
|
||||||
|
brauchten Live-Tests.
|
||||||
|
|
||||||
|
### Runde 4 – IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
|
||||||
|
|
||||||
|
| Endpoint | Vorher | Nachher |
|
||||||
|
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||||
|
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||||
|
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||||
|
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||||
|
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||||
|
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||||
|
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||||
|
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||||
|
|
||||||
|
### Runde 5 – DSGVO-GAU + Timing-Side-Channel
|
||||||
|
|
||||||
|
| Test | Vorher | Nachher |
|
||||||
|
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
|
||||||
|
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
|
||||||
|
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
|
||||||
|
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
|
||||||
|
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
|
||||||
|
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
|
||||||
|
|
||||||
|
### Runde 6 – Customer-Liste-Leak + XFF-Bypass
|
||||||
|
|
||||||
|
| Test | Vorher | Nachher |
|
||||||
|
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
|
||||||
|
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
|
||||||
|
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
|
||||||
|
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
|
||||||
|
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
|
||||||
|
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
|
||||||
|
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
|
||||||
|
|
||||||
|
### Runde 7 – SSRF + Logout
|
||||||
|
|
||||||
|
| Test | Vorher | Nachher |
|
||||||
|
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
|
||||||
|
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
|
||||||
|
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
|
||||||
|
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
|
||||||
|
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
|
||||||
|
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
|
||||||
|
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
|
||||||
|
|
||||||
|
### Runde 8 – DNS-Rebinding + Per-File-Ownership
|
||||||
|
|
||||||
|
| Test | Resultat |
|
||||||
|
| ----------------------------------------------------- | --------------------------------------------- |
|
||||||
|
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
|
||||||
|
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
|
||||||
|
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
|
||||||
|
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
|
||||||
|
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
|
||||||
|
| Ohne Token | ✅ HTTP 401 |
|
||||||
|
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
|
||||||
|
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
|
||||||
|
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
|
||||||
|
|
||||||
|
### Runde 9 – Vorher überprüft, Dependency-Audit, Audit-Chain
|
||||||
|
|
||||||
|
| Test | Resultat |
|
||||||
|
| ---------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `From`-Address-Header-Injection (CRLF in fromAddress) | ✅ bereits in Stage 3 abgefangen (`containsCRLF`) |
|
||||||
|
| `npm audit` (initial) | 9 Vulns (4× high) |
|
||||||
|
| `npm audit fix` | ✅ 8 transitive Vulns gefixt |
|
||||||
|
| nodemailer breaking-update auf 8.x | 📋 als v1.1-Item dokumentiert |
|
||||||
|
| Audit-Log Hash-Chain vor `rehashAll` | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) |
|
||||||
|
| Audit-Log Hash-Chain nach `rehashAll` | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) |
|
||||||
|
| Authenticated Rate-Limit (50 parallele Requests) | 🟡 keiner – DoS-Schutz vom Reverse-Proxy übernehmen |
|
||||||
|
| Frontend `localStorage` Token-Stealing-Vektor | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Runde-für-Runde
|
||||||
|
|
||||||
|
### Runde 1 – Erste kritische Findings (statisches Review)
|
||||||
|
|
||||||
|
- CORS komplett offen → `CORS_ORIGINS` explizit
|
||||||
|
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
|
||||||
|
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
|
||||||
|
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
|
||||||
|
- XSS via Email-Body → DOMPurify mit strikter Config
|
||||||
|
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
|
||||||
|
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
|
||||||
|
- Body-Size-Limit 5 MB
|
||||||
|
|
||||||
|
### Runde 2 – Deep-Dive (parallele Audit-Agents)
|
||||||
|
|
||||||
|
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
|
||||||
|
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
|
||||||
|
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||||
|
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
|
||||||
|
|
||||||
|
### Runde 3 – Tiefer Dive (8 weitere Hardenings)
|
||||||
|
|
||||||
|
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
|
||||||
|
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
|
||||||
|
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
|
||||||
|
- IDOR PDF-Template-Generator → `canAccessContract`
|
||||||
|
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||||
|
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||||
|
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
|
||||||
|
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||||
|
|
||||||
|
### Runde 4 – Live-Tests gegen Dev-Server (Tabelle oben)
|
||||||
|
|
||||||
|
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
|
||||||
|
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
|
||||||
|
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
|
||||||
|
|
||||||
|
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
|
||||||
|
|
||||||
|
### Runde 5 – Hack-Das-Ding-Audit
|
||||||
|
|
||||||
|
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
|
||||||
|
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
|
||||||
|
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
|
||||||
|
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
|
||||||
|
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
|
||||||
|
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
|
||||||
|
(`canAccessContract`).
|
||||||
|
|
||||||
|
### Runde 6 – Tiefer Live-Pentest (Tabelle oben)
|
||||||
|
|
||||||
|
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
|
||||||
|
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For** → `trust proxy = 'loopback'`
|
||||||
|
+ `LISTEN_ADDR=127.0.0.1` in Production
|
||||||
|
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
|
||||||
|
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
|
||||||
|
beide Fehlfälle uniform 403.
|
||||||
|
- Prisma-Error-Leaks generisch ersetzt.
|
||||||
|
|
||||||
|
### Runde 7 – Letzter Schliff
|
||||||
|
|
||||||
|
- **SSRF-Schutz** in `test-connection` und `test-mail-access` →
|
||||||
|
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
|
||||||
|
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
|
||||||
|
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
|
||||||
|
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
|
||||||
|
/ `portalTokenInvalidatedAt` auf jetzt.
|
||||||
|
|
||||||
|
### Runde 8 – Loose Ends
|
||||||
|
|
||||||
|
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
|
||||||
|
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
|
||||||
|
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI – ein
|
||||||
|
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
|
||||||
|
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
|
||||||
|
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
|
||||||
|
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
|
||||||
|
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
|
||||||
|
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
|
||||||
|
|
||||||
|
### Runde 10 – Security-Monitoring + Alerting
|
||||||
|
|
||||||
|
Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
|
||||||
|
zumindest **gesehen** werden. Ergänzt:
|
||||||
|
|
||||||
|
- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
|
||||||
|
+ Indexen für schnelles Filter+Threshold-Detection.
|
||||||
|
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
|
||||||
|
Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
|
||||||
|
(`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
|
||||||
|
(Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
|
||||||
|
- **Service `securityAlert.service.ts`** mit:
|
||||||
|
- **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
|
||||||
|
gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
|
||||||
|
HIGH/5min → erzeugt synthetische CRITICAL-Events.
|
||||||
|
- **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
|
||||||
|
(debounced, max. 1× pro Stunde + IP).
|
||||||
|
- **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
|
||||||
|
in einer Mail (wenn `monitoringDigestEnabled = true`).
|
||||||
|
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
|
||||||
|
Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
|
||||||
|
Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
|
||||||
|
Auto-Refresh alle 30s.
|
||||||
|
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
|
||||||
|
– alle hinter `settings:read` / `settings:update`.
|
||||||
|
|
||||||
|
Live-verifiziert (1. Mai 2026):
|
||||||
|
|
||||||
|
| Test | Resultat |
|
||||||
|
| --------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt |
|
||||||
|
| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event |
|
||||||
|
| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events |
|
||||||
|
| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event |
|
||||||
|
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
|
||||||
|
| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt |
|
||||||
|
| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt |
|
||||||
|
| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt |
|
||||||
|
|
||||||
|
### Runde 9 – Diminishing-Returns-Runde
|
||||||
|
|
||||||
|
Nichts Kritisches mehr gefunden. Liefert noch:
|
||||||
|
|
||||||
|
- **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability
|
||||||
|
(lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene
|
||||||
|
nodemailer-Vuln braucht Major-Update (v6 → v8) – v1.1-Item.
|
||||||
|
- **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch
|
||||||
|
frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll`
|
||||||
|
repariert; integrity-check verifiziert die Chain wieder. Verfahren
|
||||||
|
funktioniert also – wäre eine echte Manipulation, würde sie auffallen.
|
||||||
|
- **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die
|
||||||
|
zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅
|
||||||
|
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
|
||||||
|
atomar gelöscht – zweiter Versuch findet keinen Token. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
|
||||||
|
|
||||||
|
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
|
||||||
|
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
|
||||||
|
- Path-Traversal in Backup-Name (Regex blockiert)
|
||||||
|
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
|
||||||
|
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
||||||
|
- Self-grant Vollmacht via `customers/X/representatives` → 403
|
||||||
|
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
|
||||||
|
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
|
||||||
|
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
|
||||||
|
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
|
||||||
|
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
|
||||||
|
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
|
||||||
|
|
||||||
|
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
|
||||||
|
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
|
||||||
|
`<a href>`-Downloads ohne JS – v1.2-Item.
|
||||||
|
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
|
||||||
|
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
|
||||||
|
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
|
||||||
|
niedrig-Prio.
|
||||||
|
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
|
||||||
|
bei Validation-Fehler) – Defense-in-Depth-Kandidat, aber nur Admin-
|
||||||
|
erreichbar.
|
||||||
|
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
|
||||||
|
erreichbar, niedrig-Prio).
|
||||||
|
- **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur
|
||||||
|
Login + Password-Reset rate-limited. Eingeloggte User können theoretisch
|
||||||
|
hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy
|
||||||
|
(Nginx/Plesk haben eigene Limits) – nicht im App-Layer. Wenn nötig,
|
||||||
|
später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP).
|
||||||
|
- **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern,
|
||||||
|
XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via
|
||||||
|
Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System.
|
||||||
|
- **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen
|
||||||
|
(SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese
|
||||||
|
Felder nicht aus User-Input – Risiko gering, Update breaking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Production-Deployment-Checkliste
|
||||||
|
|
||||||
|
Vor dem öffentlichen Schalten muss in der Production-`.env`:
|
||||||
|
|
||||||
|
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
|
||||||
|
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
|
||||||
|
- `NODE_ENV=production`
|
||||||
|
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
|
||||||
|
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
|
||||||
|
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
|
||||||
|
hart auf die echte Client-IP gesetzt wird (nicht angefügt) – sonst
|
||||||
|
Rate-Limit-Bypass möglich.
|
||||||
|
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
|
||||||
|
durchklicken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Lazy Password-Hash-Upgrade
|
||||||
|
|
||||||
|
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
|
||||||
|
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
|
||||||
|
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an –
|
||||||
|
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
|
||||||
|
Wochen nach Deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗨️ Lehre aus der Session
|
||||||
|
|
||||||
|
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
|
||||||
|
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
|
||||||
|
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
|
||||||
|
User-zugänglich sei – war's nicht), übersehen aber, dass ein
|
||||||
|
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
|
||||||
|
|
||||||
|
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
|
||||||
|
Vor jedem Launch mit echten Tokens probieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 Commit-Historie
|
||||||
|
|
||||||
|
| Commit | Runde | Hauptthema |
|
||||||
|
| --------- | ------- | -------------------------------------------------------------- |
|
||||||
|
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
|
||||||
|
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
|
||||||
|
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
|
||||||
|
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
|
||||||
|
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
|
||||||
|
| `12b9abe` | 7 | SSRF-Schutz + Logout |
|
||||||
|
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
|
||||||
|
| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
|
||||||
|
| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Wann ist „dicht" dicht?
|
||||||
|
|
||||||
|
100 % gibt es nicht. Erreicht ist:
|
||||||
|
|
||||||
|
1. **Mehrere Audit-Methoden durch** – statisches Code-Review, parallele
|
||||||
|
Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓
|
||||||
|
2. **OWASP-Top-10 explizit getestet** – Auth, Access-Control, Injection,
|
||||||
|
Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓
|
||||||
|
3. **Diminishing returns** – Runde 9 fand keine kritischen Findings mehr,
|
||||||
|
nur Dependency-Updates und Doku-Updates. ✓
|
||||||
|
4. **Production-Deployment-Checkliste klar.** ✓
|
||||||
|
5. **Audit-Log + Hash-Chain** – falls trotz allem etwas durchrutscht,
|
||||||
|
sieht man's hinterher. ✓
|
||||||
|
|
||||||
|
Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`),
|
||||||
|
neue Angriffsklassen, Server-Misconfig in Production, Social Engineering.
|
||||||
|
Dafür gibt's keine Code-Lösung – nur Monitoring und Rotation der Secrets.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Security-Review vor 1.0.0
|
# Security-Review vor 1.0.0
|
||||||
|
|
||||||
|
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
|
||||||
|
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
|
||||||
|
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
|
||||||
|
|
||||||
> **Version 2** – dieser Review wurde in 2 Runden durchgeführt.
|
> **Version 2** – dieser Review wurde in 2 Runden durchgeführt.
|
||||||
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
|
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
|
||||||
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents – fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
|
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents – fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
|
||||||
|
|||||||
+94
-121
@@ -5,7 +5,7 @@
|
|||||||
## 🔜 Offen
|
## 🔜 Offen
|
||||||
|
|
||||||
### Manuelle Tests (vor Release durchklicken)
|
### Manuelle Tests (vor Release durchklicken)
|
||||||
Checklisten für Security + Email-Log-System stehen in **[docs/TESTING.md](../docs/TESTING.md)**.
|
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
|
||||||
Einmal komplett durchlaufen vor v1.0.0-Release.
|
Einmal komplett durchlaufen vor v1.0.0-Release.
|
||||||
|
|
||||||
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
||||||
@@ -97,6 +97,78 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🐛 PDF-Vorschau im PDF-Template-Editor lädt nicht**
|
||||||
|
- CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
|
||||||
|
der eigenen Resourcen, auch same-origin – Browser zeigte je nach
|
||||||
|
Variante "Verbindung abgelehnt" oder CSP-Violation.
|
||||||
|
- Fix: `frame-ancestors 'self'` (statt `'none'`). App darf eigene
|
||||||
|
Resourcen embeden (z.B. die annotierte PDF-Vorschau), externe Sites
|
||||||
|
bleiben weiterhin gesperrt.
|
||||||
|
|
||||||
|
- [x] **🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)**
|
||||||
|
- `./factory-export.sh` zieht eine ZIP per API in `factory-exports/`
|
||||||
|
(gitignored Drop-Box).
|
||||||
|
- `./factory-import.sh [zip]` lädt die ZIP per API in eine andere Instanz
|
||||||
|
– ohne Argument wählt es die jüngste ZIP automatisch.
|
||||||
|
- `./factory-import.sh --save-as-builtin` entpackt die ZIP zusätzlich nach
|
||||||
|
`backend/factory-defaults/` (vorher aufgeräumt). Damit landet sie beim
|
||||||
|
nächsten `docker-compose up --build` als Werkseinstellung im Image und
|
||||||
|
seedet frische DBs automatisch.
|
||||||
|
- Konfigurierbar per Env: `OPENCRM_URL`, `OPENCRM_EMAIL`,
|
||||||
|
`OPENCRM_PASSWORD` (sonst interaktive Abfrage).
|
||||||
|
- README-Abschnitt „Factory-Defaults: Stammdaten-Kataloge teilen"
|
||||||
|
komplett überarbeitet (drei Transport-Pfade, Auto-Seed, Whitelist).
|
||||||
|
|
||||||
|
- [x] **🚀 Auto-Seed: Werkseinstellungen beim Erst-Deploy**
|
||||||
|
- Inhalt von `backend/factory-defaults/` wird via Dockerfile als
|
||||||
|
`/app/factory-defaults-builtin/` ins Image gebrannt.
|
||||||
|
- Entrypoint spielt sie nach erfolgreichem Auto-Seed (frische DB) automatisch
|
||||||
|
via `tsx scripts/seed-factory-defaults.ts` ein – steuerbar über
|
||||||
|
`FACTORY_DEFAULTS_DIR`.
|
||||||
|
- Damit bringen neue VMs sofort Anbieter, Tarife, PDF-Auftragsvorlagen +
|
||||||
|
Datenschutzerklärung/Impressum mit, ohne manuelles UI-/CLI-Import.
|
||||||
|
- Bestehende Installs werden NIE überschrieben (Trigger nur wenn der
|
||||||
|
Auto-Seed im selben Start-Lauf gelaufen ist).
|
||||||
|
|
||||||
|
- [x] **📦 Factory-Defaults: HTML-Templates + Import via UI**
|
||||||
|
- Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
|
||||||
|
werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner,
|
||||||
|
Whitelist-geschützt – andere AppSetting-Keys werden ignoriert).
|
||||||
|
- Import läuft jetzt auch über die UI (Einstellungen → Factory-Defaults →
|
||||||
|
„ZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und
|
||||||
|
wurde gleichermaßen um die HTML-Templates erweitert.
|
||||||
|
- Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen →
|
||||||
|
Import → Wert wieder vollständig hergestellt; Counts in Audit-Log.
|
||||||
|
|
||||||
|
- [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
|
||||||
|
- Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
|
||||||
|
`hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen
|
||||||
|
DSGVO/Developer waren in der UI nicht zuweisbar (Checkbox ohne Wirkung).
|
||||||
|
- Beide Felder zur Whitelist hinzugefügt + Audit-Log liest die Pre-Werte
|
||||||
|
jetzt aus den geladenen Rollen (kein False-Positive-Change mehr).
|
||||||
|
|
||||||
|
- [x] **🔒 HTTPS-only-Header per Flag (`HTTPS_ENABLED`)**
|
||||||
|
- HSTS + `upgrade-insecure-requests` (CSP) sperrten den Browser bei
|
||||||
|
direktem `http://ip:port`-Zugriff aus (`ERR_SSL_PROTOCOL_ERROR`).
|
||||||
|
- Beide Header default OFF, kommen nur mit `HTTPS_ENABLED=true` (sobald
|
||||||
|
TLS-Reverse-Proxy davor steht).
|
||||||
|
|
||||||
|
- [x] **🗃️ Prisma-Migrations-System (statt `db push`)**
|
||||||
|
- Initial-Migration `0_init` aus aktuellem Schema generiert
|
||||||
|
(`prisma migrate diff --from-empty --to-schema-datamodel`).
|
||||||
|
- 24 alte gedriftete Migrations gelöscht – frischer Start.
|
||||||
|
- `migration_lock.toml` für MySQL hinzugefügt.
|
||||||
|
- Container-Entrypoint umgebaut:
|
||||||
|
- Auto-Baseline-Detection: bestehende DB ohne `_prisma_migrations` →
|
||||||
|
`migrate resolve --applied 0_init` läuft automatisch.
|
||||||
|
- Statt `db push --accept-data-loss` jetzt `migrate deploy` (idempotent,
|
||||||
|
datenerhaltend, keine stillen DROPs mehr).
|
||||||
|
- Neuer npm-Script `schema:sync` (lokal/Dev): legt automatisch eine
|
||||||
|
versionierte Migration mit Zeitstempel-Namen an
|
||||||
|
(`prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)`).
|
||||||
|
- Workflow ab jetzt: schema.prisma ändern → `npm run schema:sync` →
|
||||||
|
Migration committen → Push → Container-Restart wendet sie automatisch an.
|
||||||
|
|
||||||
- [x] **🔄 Automatische Vertrags-Status-Übergänge**
|
- [x] **🔄 Automatische Vertrags-Status-Übergänge**
|
||||||
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
|
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
|
||||||
`status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log).
|
`status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log).
|
||||||
@@ -116,126 +188,27 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
||||||
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
||||||
|
|
||||||
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)**
|
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
|
||||||
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
||||||
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
|
||||||
- CORS offen → `CORS_ORIGINS` explizit
|
- Erste 2 Runden zusätzlich ausführlich in
|
||||||
- Helmet + Security-Headers
|
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
|
||||||
- JWT-Fallback-Secret entfernt (Fail-Fast beim Start)
|
- Highlights:
|
||||||
- IDOR bei 7 Contract-Endpoints
|
- Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
|
||||||
- XSS via Email-Body (DOMPurify)
|
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
|
||||||
- Customer-API Data Exposure (Passwort-Hashes)
|
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
|
||||||
- Portal-JWT-Invalidation nach Passwort-Reset
|
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
|
||||||
- Body-Size-Limit 5 MB
|
Privacy-Policy-XSS
|
||||||
- **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:**
|
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
|
||||||
- Zip-Slip im Backup-Upload (Arbitrary File Write!)
|
Self-Grant + Existence-Disclosure
|
||||||
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
|
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
|
||||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
||||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
|
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
|
||||||
- **Runde 3 – Tiefer Dive (8 weitere Hardenings):**
|
neuen Critical-Findings → diminishing returns erreicht
|
||||||
- JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt
|
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
|
||||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam)
|
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
|
||||||
- IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId
|
Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
|
||||||
- IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract`
|
- Deployment-Checkliste komplett (in HARDENING.md)
|
||||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
|
||||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
|
||||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
|
|
||||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
|
||||||
- **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):**
|
|
||||||
- 🚨 **`GET /api/customers` leakte als Portal-User die komplette
|
|
||||||
Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der
|
|
||||||
Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List-
|
|
||||||
Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene
|
|
||||||
Kunden (Filter im Controller).
|
|
||||||
- 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche
|
|
||||||
mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1`
|
|
||||||
hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` –
|
|
||||||
XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt
|
|
||||||
(= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production-
|
|
||||||
Default, damit das Backend nicht direkt von außen ansprechbar ist.
|
|
||||||
- **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**:
|
|
||||||
- Portal-User konnte sich selbst Vollmacht erteilen (1→1) und
|
|
||||||
Datensätze für beliebige `representativeId`s anlegen (auch nicht-
|
|
||||||
existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak).
|
|
||||||
- 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range.
|
|
||||||
- Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`-
|
|
||||||
Verhältnis in einem Query, beide Fehlfälle identisch 403.
|
|
||||||
- **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine
|
|
||||||
Prisma-Stacks mehr im Response.
|
|
||||||
- Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte),
|
|
||||||
Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth
|
|
||||||
auf `/api/customers/:id` 200/403 (kein 404-Leak).
|
|
||||||
|
|
||||||
**Geprüft + sauber (Runde 6):**
|
|
||||||
- Prototype Pollution beim Login → kein Effekt
|
|
||||||
- HTTP-Method-Override via Header → ignoriert
|
|
||||||
- Path-Traversal in Backup-Name → durch Regex blockiert
|
|
||||||
- Developer-Routes existieren nicht (404)
|
|
||||||
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
|
|
||||||
- Self-grant Vollmacht via `customers/X/representatives` → 403 (perm)
|
|
||||||
- `/api/customers/:id` GET: 200 für eigene, 403 sonst (kein 404-Leak)
|
|
||||||
|
|
||||||
**Offen für v1.1:**
|
|
||||||
- `/api/contracts/:id` GET liefert 404 für nicht-existente IDs (Existence-
|
|
||||||
Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten
|
|
||||||
korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer.
|
|
||||||
- Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei
|
|
||||||
Validation-Fehler) – Defense-in-Depth-Kandidat.
|
|
||||||
|
|
||||||
- **Runde 5 – Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):**
|
|
||||||
- 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) – jetzt hinter
|
|
||||||
`authenticate`. Direkte <a href>-Links nutzen `?token=...` Query-Parameter,
|
|
||||||
unterstützt von auth-Middleware. Frontend-Helper `fileUrl(path)` hängt
|
|
||||||
Token automatisch an, 24 URLs migriert (CustomerDetail, ContractDetail,
|
|
||||||
InvoicesSection, PdfTemplates, GDPRDashboard).
|
|
||||||
- **Login-Timing-Side-Channel**: Bei ungültigem User fehlte `bcrypt.compare`
|
|
||||||
→ 110ms vs 10ms, User-Enumeration trivial. Jetzt Dummy-bcrypt-compare
|
|
||||||
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
|
|
||||||
Live-verifiziert: 422ms vs 425ms – Timing-Angriff dicht.
|
|
||||||
- **XSS via Privacy Policy / Imprint**: 4 Frontend-Seiten renderten
|
|
||||||
Backend-HTML ohne DOMPurify (`PortalPrivacy`, `ConsentPage`,
|
|
||||||
`PortalWebsitePrivacy`, `PortalImprint`). Admin-eingegebene
|
|
||||||
`<script>`-Tags wären bei jedem Portal-Kunden-Besuch ausgeführt worden.
|
|
||||||
Jetzt mit strikter Sanitize-Config (FORBID_TAGS/ATTR).
|
|
||||||
- **IDOR-Härtung Upload/Delete/SaveAttachment**: `canAccessContract` jetzt
|
|
||||||
in `uploadContractDocument`, `deleteContractDocument`, im generischen
|
|
||||||
`handleContractDocumentUpload` (Kündigungsschreiben + -bestätigungen)
|
|
||||||
und in `saveAttachmentAsContractDocument`. Defense-in-Depth, blockt
|
|
||||||
auch bei künftigen Staff-Scoping-Rollen.
|
|
||||||
- Global Error-Handler: `err.status` wird respektiert (413/400 statt 500).
|
|
||||||
|
|
||||||
**Offen für v1.1**:
|
|
||||||
- Per-File-Ownership-Check bei `/api/uploads/*` (aktuell reicht
|
|
||||||
Authentifizierung, kein Datei-spezifischer Owner-Check). Implementierung
|
|
||||||
bräuchte dedizierten `GET /api/files/download?path=...`-Endpoint mit
|
|
||||||
DB-Lookup, welche Ressource zur Datei gehört.
|
|
||||||
- TipTap-Link-Tool: `javascript:`-Protokoll blockieren (Admin-only erreichbar,
|
|
||||||
niedrig-Prio).
|
|
||||||
|
|
||||||
- **Runde 4 – Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:**
|
|
||||||
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
|
|
||||||
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt
|
|
||||||
- Portal-Kunde konnte live per `GET /api/customers/<fremde-id>` kompletten Fremdkunden-Datensatz auslesen → jetzt 403
|
|
||||||
- Error-Handler: `err.status` wird jetzt respektiert (413/400 statt pauschalem 500)
|
|
||||||
|
|
||||||
**Live-verifiziert als Portal-Kunde gegen fremden Test-Kunden #4:**
|
|
||||||
|
|
||||||
| Endpoint | Vorher | Nachher |
|
|
||||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
|
||||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
|
||||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
|
||||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
|
||||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
|
||||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
|
||||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
|
||||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
|
||||||
|
|
||||||
- Deployment-Checkliste komplett
|
|
||||||
|
|
||||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||||
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
|
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Factory-Defaults-Export – holt eine ZIP vom laufenden OpenCRM und legt sie
|
||||||
|
# in ./factory-exports/ ab. Dieselbe ZIP, die du auch über die UI bekommst.
|
||||||
|
#
|
||||||
|
# Workflow:
|
||||||
|
# ./factory-export.sh # default: localhost:3010, admin@admin.com
|
||||||
|
# OPENCRM_URL=https://crm.example.de \
|
||||||
|
# OPENCRM_EMAIL=admin@example.de \
|
||||||
|
# ./factory-export.sh # gegen die Prod-Instanz
|
||||||
|
#
|
||||||
|
# Optional:
|
||||||
|
# OPENCRM_PASSWORD=… (sonst wird interaktiv abgefragt)
|
||||||
|
#
|
||||||
|
# Die ZIP ist gitignored – du kannst sie via scp transferieren und mit
|
||||||
|
# ./factory-import.sh auf der anderen Seite einspielen.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
URL="${OPENCRM_URL:-http://localhost:3010}"
|
||||||
|
EMAIL="${OPENCRM_EMAIL:-admin@admin.com}"
|
||||||
|
PASSWORD="${OPENCRM_PASSWORD:-}"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
EXPORT_DIR="$REPO_ROOT/factory-exports"
|
||||||
|
mkdir -p "$EXPORT_DIR"
|
||||||
|
|
||||||
|
if [ -z "$PASSWORD" ]; then
|
||||||
|
read -r -s -p "Passwort für $EMAIL @ $URL: " PASSWORD
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Login als $EMAIL @ $URL"
|
||||||
|
LOGIN_RESPONSE="$(curl -sS -X POST "$URL/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-raw "$(E="$EMAIL" P="$PASSWORD" python3 -c 'import json,os;print(json.dumps({"email":os.environ["E"],"password":os.environ["P"]}))')")"
|
||||||
|
|
||||||
|
TOKEN="$(printf '%s' "$LOGIN_RESPONSE" | python3 -c 'import json,sys;d=json.load(sys.stdin);print((d.get("data") or {}).get("token","") or d.get("token",""))')"
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "✗ Login fehlgeschlagen. Antwort:"
|
||||||
|
echo "$LOGIN_RESPONSE" | head -c 500
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y-%m-%d-%H%M)"
|
||||||
|
DEST="$EXPORT_DIR/factory-defaults-$TIMESTAMP.zip"
|
||||||
|
|
||||||
|
echo "→ Lade ZIP nach $DEST"
|
||||||
|
HTTP_CODE="$(curl -sS -o "$DEST" -w '%{http_code}' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$URL/api/factory-defaults/export")"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo "✗ Export-Endpoint antwortete mit HTTP $HTTP_CODE"
|
||||||
|
rm -f "$DEST"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SIZE_KB="$(du -k "$DEST" | cut -f1)"
|
||||||
|
echo "✓ Export erfolgreich: $DEST (${SIZE_KB} KB)"
|
||||||
|
echo
|
||||||
|
echo "Inhalt:"
|
||||||
|
unzip -l "$DEST" | sed 's/^/ /'
|
||||||
Executable
+140
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Factory-Defaults-Import – pflegt eine ZIP in eine OpenCRM-Instanz ein.
|
||||||
|
# Idempotent (upserts pro Kategorie, nichts wird gelöscht).
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# ./factory-import.sh # jüngste ZIP aus factory-exports/
|
||||||
|
# ./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
|
||||||
|
# ./factory-import.sh --save-as-builtin # nach Import auch ins
|
||||||
|
# ./factory-import.sh --save-as-builtin ./foo.zip # backend/factory-defaults/
|
||||||
|
# # entpacken → nächster
|
||||||
|
# # Image-Build hat sie
|
||||||
|
# # als Werkseinstellung
|
||||||
|
#
|
||||||
|
# ENV (wie factory-export.sh):
|
||||||
|
# OPENCRM_URL (default http://localhost:3010)
|
||||||
|
# OPENCRM_EMAIL (default admin@admin.com)
|
||||||
|
# OPENCRM_PASSWORD (sonst interaktiv)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
URL="${OPENCRM_URL:-http://localhost:3010}"
|
||||||
|
EMAIL="${OPENCRM_EMAIL:-admin@admin.com}"
|
||||||
|
PASSWORD="${OPENCRM_PASSWORD:-}"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
EXPORT_DIR="$REPO_ROOT/factory-exports"
|
||||||
|
BUILTIN_DIR="$REPO_ROOT/backend/factory-defaults"
|
||||||
|
|
||||||
|
# Argumente parsen: erlaubt sind --save-as-builtin und 0/1 ZIP-Pfade in
|
||||||
|
# beliebiger Reihenfolge.
|
||||||
|
SAVE_AS_BUILTIN=false
|
||||||
|
ZIP_PATH=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--save-as-builtin) SAVE_AS_BUILTIN=true ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,16p' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--*) echo "✗ Unbekanntes Flag: $arg"; exit 2 ;;
|
||||||
|
*)
|
||||||
|
if [ -n "$ZIP_PATH" ]; then
|
||||||
|
echo "✗ Mehrere ZIP-Pfade angegeben (nur einer erlaubt)"; exit 2
|
||||||
|
fi
|
||||||
|
ZIP_PATH="$arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ZIP_PATH" ]; then
|
||||||
|
# Jüngste ZIP automatisch wählen
|
||||||
|
ZIP_PATH="$(ls -1t "$EXPORT_DIR"/*.zip 2>/dev/null | head -1 || true)"
|
||||||
|
if [ -z "$ZIP_PATH" ]; then
|
||||||
|
echo "✗ Keine ZIP angegeben und keine in $EXPORT_DIR/ gefunden."
|
||||||
|
echo " Aufruf: ./factory-import.sh <pfad/zur/factory-defaults.zip>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "→ Keine ZIP angegeben – nehme jüngste aus $EXPORT_DIR/:"
|
||||||
|
echo " $(basename "$ZIP_PATH")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ZIP_PATH" ]; then
|
||||||
|
echo "✗ Datei nicht gefunden: $ZIP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PASSWORD" ]; then
|
||||||
|
read -r -s -p "Passwort für $EMAIL @ $URL: " PASSWORD
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Login als $EMAIL @ $URL"
|
||||||
|
LOGIN_RESPONSE="$(curl -sS -X POST "$URL/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-raw "$(E="$EMAIL" P="$PASSWORD" python3 -c 'import json,os;print(json.dumps({"email":os.environ["E"],"password":os.environ["P"]}))')")"
|
||||||
|
|
||||||
|
TOKEN="$(printf '%s' "$LOGIN_RESPONSE" | python3 -c 'import json,sys;d=json.load(sys.stdin);print((d.get("data") or {}).get("token","") or d.get("token",""))')"
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "✗ Login fehlgeschlagen. Antwort:"
|
||||||
|
echo "$LOGIN_RESPONSE" | head -c 500
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Upload + Import: $(basename "$ZIP_PATH")"
|
||||||
|
RESPONSE="$(curl -sS -X POST "$URL/api/factory-defaults/import" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "zip=@$ZIP_PATH")"
|
||||||
|
|
||||||
|
# Hübsch ausgeben + auf success prüfen
|
||||||
|
if ! printf '%s' "$RESPONSE" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
if not r.get("success"):
|
||||||
|
print("✗ Import fehlgeschlagen:", r.get("error", "(unbekannt)"))
|
||||||
|
sys.exit(1)
|
||||||
|
d = r.get("data", {})
|
||||||
|
print("✓ Import erfolgreich:")
|
||||||
|
for label, key in [
|
||||||
|
("Anbieter", "providers"),
|
||||||
|
("Tarife", "tariffs"),
|
||||||
|
("Kündigungsfristen", "cancellationPeriods"),
|
||||||
|
("Laufzeiten", "contractDurations"),
|
||||||
|
("Vertragskategorien","contractCategories"),
|
||||||
|
("PDF-Vorlagen", "pdfTemplates"),
|
||||||
|
("HTML-Templates", "appSettings"),
|
||||||
|
]:
|
||||||
|
print(f" {label}: {d.get(key, 0)}")
|
||||||
|
skipped = d.get("pdfTemplatesSkipped", 0)
|
||||||
|
if skipped:
|
||||||
|
print(f" (PDF-Vorlagen übersprungen: {skipped})")
|
||||||
|
warnings = d.get("warnings", []) or []
|
||||||
|
if warnings:
|
||||||
|
print("Hinweise:")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" - {w}")
|
||||||
|
'; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --save-as-builtin: ZIP zusätzlich in backend/factory-defaults/ entpacken,
|
||||||
|
# damit der nächste Image-Build sie als Werkseinstellung mitnimmt.
|
||||||
|
# Vorher räumen wir auf (außer README.md + .gitkeep), damit nichts Veraltetes
|
||||||
|
# liegen bleibt.
|
||||||
|
if [ "$SAVE_AS_BUILTIN" = "true" ]; then
|
||||||
|
echo
|
||||||
|
echo "→ --save-as-builtin: aktualisiere $BUILTIN_DIR/"
|
||||||
|
if [ ! -d "$BUILTIN_DIR" ]; then
|
||||||
|
mkdir -p "$BUILTIN_DIR"
|
||||||
|
fi
|
||||||
|
# Aufräumen: alles außer README.md und .gitkeep löschen
|
||||||
|
find "$BUILTIN_DIR" -mindepth 1 \
|
||||||
|
\! -name 'README.md' \! -name '.gitkeep' \
|
||||||
|
-delete
|
||||||
|
# ZIP entpacken (manifest.json kommt mit, ist aber harmlos)
|
||||||
|
unzip -q -o "$ZIP_PATH" -d "$BUILTIN_DIR"
|
||||||
|
echo "✓ Werkseinstellungen aktualisiert. Beim nächsten 'docker-compose up"
|
||||||
|
echo " --build' landen sie im Image und seeden frische DBs automatisch."
|
||||||
|
fi
|
||||||
+2
-1
@@ -5,10 +5,11 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist-ssr/
|
dist-ssr/
|
||||||
|
|
||||||
# Environment
|
# Environment – echte Secrets blocken, .env.example weiter mittracken
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import DatabaseBackup from './pages/settings/DatabaseBackup';
|
|||||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||||
import AuditLogs from './pages/settings/AuditLogs';
|
import AuditLogs from './pages/settings/AuditLogs';
|
||||||
import EmailLogPage from './pages/settings/EmailLogs';
|
import EmailLogPage from './pages/settings/EmailLogs';
|
||||||
|
import Monitoring from './pages/settings/Monitoring';
|
||||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||||
import UserList from './pages/users/UserList';
|
import UserList from './pages/users/UserList';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
@@ -202,6 +203,7 @@ function App() {
|
|||||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||||
|
<Route path="settings/monitoring" element={<Monitoring />} />
|
||||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
@@ -238,6 +238,27 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('settings:read') && (
|
||||||
|
<Link
|
||||||
|
to="/settings/monitoring"
|
||||||
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
|
||||||
|
<ShieldAlert className="w-6 h-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
|
||||||
|
Sicherheits-Monitoring
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{hasPermission('gdpr:admin') && (
|
{hasPermission('gdpr:admin') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/gdpr"
|
to="/settings/gdpr"
|
||||||
|
|||||||
@@ -1514,6 +1514,26 @@ export default function ContractDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VVL = Vertragsverlängerung beim selben Anbieter (alle Daten 1:1 + Datum berechnet)
|
||||||
|
const renewalMutation = useMutation({
|
||||||
|
mutationFn: () => contractApi.createRenewal(contractId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.data) {
|
||||||
|
navigate(`/contracts/${data.data.id}/edit`);
|
||||||
|
} else {
|
||||||
|
alert('VVL wurde erstellt, aber keine ID zurückgegeben');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('VVL Fehler:', error);
|
||||||
|
alert(`Fehler beim Erstellen der VVL: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dropdown-Toggle für VVL
|
||||||
|
const [showFollowUpMenu, setShowFollowUpMenu] = useState(false);
|
||||||
|
const [showVvlConfirm, setShowVvlConfirm] = useState(false);
|
||||||
|
|
||||||
// Un-Snooze Mutation
|
// Un-Snooze Mutation
|
||||||
const unsnoozeMutation = useMutation({
|
const unsnoozeMutation = useMutation({
|
||||||
mutationFn: () => contractApi.snooze(contractId, {}),
|
mutationFn: () => contractApi.snooze(contractId, {}),
|
||||||
@@ -1756,14 +1776,50 @@ export default function ContractDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{hasPermission('contracts:create') && !c.followUpContract && (
|
{hasPermission('contracts:create') && !c.followUpContract && (
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
{/* Hauptaktion: Folgevertrag anlegen */}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setShowFollowUpConfirm(true)}
|
onClick={() => setShowFollowUpConfirm(true)}
|
||||||
disabled={followUpMutation.isPending}
|
disabled={followUpMutation.isPending || renewalMutation.isPending}
|
||||||
|
className="!rounded-r-none !border-r-0"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
{followUpMutation.isPending ? 'Erstelle...' : 'Folgevertrag anlegen'}
|
{followUpMutation.isPending ? 'Erstelle...' : 'Folgevertrag anlegen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Dropdown-Pfeil für VVL */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowFollowUpMenu(!showFollowUpMenu)}
|
||||||
|
disabled={followUpMutation.isPending || renewalMutation.isPending}
|
||||||
|
className="!rounded-l-none !px-2"
|
||||||
|
title="Weitere Optionen"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showFollowUpMenu && (
|
||||||
|
<>
|
||||||
|
{/* Click-outside-Overlay */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowFollowUpMenu(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute top-full right-0 mt-1 z-20 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFollowUpMenu(false);
|
||||||
|
setShowVvlConfirm(true);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 text-gray-500" />
|
||||||
|
VVL anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.followUpContract && (
|
{c.followUpContract && (
|
||||||
<Link to={`/contracts/${c.followUpContract.id}`}>
|
<Link to={`/contracts/${c.followUpContract.id}`}>
|
||||||
@@ -3077,6 +3133,53 @@ export default function ContractDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* VVL Bestätigung */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showVvlConfirm}
|
||||||
|
onClose={() => setShowVvlConfirm(false)}
|
||||||
|
title="Vertragsverlängerung (VVL) anlegen"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Möchten Sie eine Vertragsverlängerung für diesen Vertrag anlegen?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Alle Daten werden 1:1 übernommen (auch Provider, Tarif, Portal-
|
||||||
|
Zugang, Preise und Vertragsdokumente). Das Startdatum wird auf
|
||||||
|
den nächsten Laufzeit-Beginn berechnet (altes Startdatum +
|
||||||
|
Vertragslaufzeit). Das <strong>Auftragsdokument</strong> wird
|
||||||
|
<strong> nicht </strong> mitkopiert – das ist die neue,
|
||||||
|
unterschriebene VVL, die Sie selbst hochladen.
|
||||||
|
</p>
|
||||||
|
{c.startDate && c.contractDuration?.description && (
|
||||||
|
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||||
|
Vorhersage: alter Beginn{' '}
|
||||||
|
<strong>{new Date(c.startDate).toLocaleDateString('de-DE')}</strong> +{' '}
|
||||||
|
<strong>{c.contractDuration.description}</strong>
|
||||||
|
{' = '}neuer VVL-Beginn (siehe danach im Vertrag)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowVvlConfirm(false)}
|
||||||
|
>
|
||||||
|
Nein
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowVvlConfirm(false);
|
||||||
|
renewalMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={renewalMutation.isPending}
|
||||||
|
>
|
||||||
|
{renewalMutation.isPending ? 'Erstelle...' : 'Ja, VVL anlegen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Status-Info Modal */}
|
{/* Status-Info Modal */}
|
||||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Download,
|
Download,
|
||||||
|
Upload,
|
||||||
Package,
|
Package,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
FileType,
|
FileType,
|
||||||
FileText,
|
FileText,
|
||||||
|
FileCode,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -27,6 +29,19 @@ interface PreviewCounts {
|
|||||||
contractDurations: number;
|
contractDurations: number;
|
||||||
contractCategories: number;
|
contractCategories: number;
|
||||||
pdfTemplates: number;
|
pdfTemplates: number;
|
||||||
|
appSettings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
providers: number;
|
||||||
|
tariffs: number;
|
||||||
|
cancellationPeriods: number;
|
||||||
|
contractDurations: number;
|
||||||
|
contractCategories: number;
|
||||||
|
pdfTemplates: number;
|
||||||
|
pdfTemplatesSkipped: number;
|
||||||
|
appSettings: number;
|
||||||
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FactoryDefaults() {
|
export default function FactoryDefaults() {
|
||||||
@@ -34,6 +49,12 @@ export default function FactoryDefaults() {
|
|||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const [downloadDone, setDownloadDone] = useState(false);
|
const [downloadDone, setDownloadDone] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: previewData, isLoading } = useQuery({
|
const { data: previewData, isLoading } = useQuery({
|
||||||
queryKey: ['factory-defaults-preview'],
|
queryKey: ['factory-defaults-preview'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -86,9 +107,39 @@ export default function FactoryDefaults() {
|
|||||||
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
|
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
|
||||||
{ icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' },
|
{ icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' },
|
||||||
{ icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' },
|
{ icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' },
|
||||||
|
{ icon: FileCode, label: 'HTML-Templates', count: counts.appSettings ?? 0, color: 'text-teal-600' },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
setImporting(true);
|
||||||
|
setImportError(null);
|
||||||
|
setImportResult(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('zip', file);
|
||||||
|
const res = await api.post<{ success: boolean; data: ImportResult; error?: string }>(
|
||||||
|
'/factory-defaults/import',
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||||
|
);
|
||||||
|
if (!res.data.success) {
|
||||||
|
throw new Error(res.data.error || 'Fehler beim Import');
|
||||||
|
}
|
||||||
|
setImportResult(res.data.data);
|
||||||
|
// Caches invalidieren – neue Anbieter, Tarife, Vorlagen tauchen sofort
|
||||||
|
// an anderer Stelle (Provider-Liste, Vertrag-Anlage, …) auf.
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
} catch (err: any) {
|
||||||
|
setImportError(
|
||||||
|
err?.response?.data?.error || err?.message || 'Fehler beim Import',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
@@ -109,14 +160,15 @@ export default function FactoryDefaults() {
|
|||||||
<div className="text-sm text-blue-900 space-y-1">
|
<div className="text-sm text-blue-900 space-y-1">
|
||||||
<p className="font-medium">Was sind Factory-Defaults?</p>
|
<p className="font-medium">Was sind Factory-Defaults?</p>
|
||||||
<p>
|
<p>
|
||||||
Das sind <strong>reine Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
|
Das sind <strong>Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
|
||||||
Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie
|
Kündigungsfristen, Vertragskategorien, PDF-Auftragsvorlagen und die
|
||||||
exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu
|
HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage,
|
||||||
verwenden.
|
Website-Datenschutz). Du kannst sie exportieren, um sie in anderen
|
||||||
|
OpenCRM-Installationen als Startpunkt zu verwenden.
|
||||||
</p>
|
</p>
|
||||||
<p className="pt-1">
|
<p className="pt-1">
|
||||||
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, Emails
|
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, E-Mails
|
||||||
oder Einstellungen – dafür gibt es den separaten{' '}
|
oder Konfigurationen (SMTP, Secrets) – dafür gibt es den separaten{' '}
|
||||||
<Link to="/settings/database-backup" className="underline">
|
<Link to="/settings/database-backup" className="underline">
|
||||||
Datenbank-Backup
|
Datenbank-Backup
|
||||||
</Link>
|
</Link>
|
||||||
@@ -127,16 +179,10 @@ export default function FactoryDefaults() {
|
|||||||
|
|
||||||
<Card title="Export" className="mb-6">
|
<Card title="Export" className="mb-6">
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und
|
Erstellt ein ZIP mit allen Kataloge-Daten, PDF-Auftragsvorlagen und den
|
||||||
entpacke den Inhalt in einer anderen Installation unter{' '}
|
HTML-Standardtexten (Datenschutz / Impressum / Vollmacht). In einer anderen
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
OpenCRM-Installation kannst du es dann unten unter <strong>Import</strong> wieder
|
||||||
backend/factory-defaults/
|
einspielen.
|
||||||
</code>
|
|
||||||
, dann dort{' '}
|
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
|
||||||
npm run seed:defaults
|
|
||||||
</code>{' '}
|
|
||||||
ausführen.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -191,34 +237,88 @@ export default function FactoryDefaults() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Import">
|
<Card title="Import">
|
||||||
<div className="space-y-3 text-sm text-gray-600">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
<p>
|
Lade hier eine zuvor exportierte Factory-Defaults-ZIP hoch. Bestehende Einträge
|
||||||
Der Import läuft über ein Kommandozeilen-Script – dadurch bleibt klar, was wann
|
werden anhand des Unique-Keys (Name / Code) <strong>aktualisiert</strong>, neue
|
||||||
passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb.
|
werden angelegt. Es wird nichts gelöscht – der Vorgang ist idempotent.
|
||||||
</p>
|
</p>
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
|
||||||
<li>
|
<input
|
||||||
ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '}
|
ref={fileInputRef}
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
type="file"
|
||||||
backend/factory-defaults/
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
</code>{' '}
|
className="hidden"
|
||||||
entpacken
|
onChange={(e) => {
|
||||||
</li>
|
const f = e.target.files?.[0];
|
||||||
<li>
|
if (f) handleImport(f);
|
||||||
Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged)
|
}}
|
||||||
</li>
|
/>
|
||||||
<li>
|
|
||||||
Im Backend-Ordner:{' '}
|
<div className="flex items-center gap-3">
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Import läuft…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
ZIP hochladen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Alternativ:{' '}
|
||||||
|
<code className="bg-gray-100 px-1.5 py-0.5 rounded">
|
||||||
npm run seed:defaults
|
npm run seed:defaults
|
||||||
</code>
|
</code>{' '}
|
||||||
</li>
|
im Backend
|
||||||
</ol>
|
</span>
|
||||||
<p className="pt-2">
|
|
||||||
Das Script läuft <strong>idempotent</strong> – gleiche Einträge werden per
|
|
||||||
unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
|
||||||
|
<div className="flex items-center gap-2 font-medium mb-2">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Import erfolgreich
|
||||||
|
</div>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||||
|
<li>Anbieter: {importResult.providers}</li>
|
||||||
|
<li>Tarife: {importResult.tariffs}</li>
|
||||||
|
<li>Kündigungsfristen: {importResult.cancellationPeriods}</li>
|
||||||
|
<li>Laufzeiten: {importResult.contractDurations}</li>
|
||||||
|
<li>Vertragskategorien: {importResult.contractCategories}</li>
|
||||||
|
<li>
|
||||||
|
PDF-Vorlagen: {importResult.pdfTemplates}
|
||||||
|
{importResult.pdfTemplatesSkipped > 0 &&
|
||||||
|
` (${importResult.pdfTemplatesSkipped} übersprungen)`}
|
||||||
|
</li>
|
||||||
|
<li>HTML-Templates: {importResult.appSettings}</li>
|
||||||
|
</ul>
|
||||||
|
{importResult.warnings.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-green-200 text-amber-700 text-xs">
|
||||||
|
<div className="font-medium mb-1">Hinweise:</div>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
|
{importResult.warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importError && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-sm text-red-700">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{importError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Input from '../../components/ui/Input';
|
||||||
|
import Select from '../../components/ui/Select';
|
||||||
|
import Modal from '../../components/ui/Modal';
|
||||||
|
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die anzuzeigenden Seitenzahlen für die Pagination.
|
||||||
|
* Bis zu 10 Seitenzahlen, current möglichst mittig.
|
||||||
|
*/
|
||||||
|
function paginationWindow(current: number, total: number, size = 10): number[] {
|
||||||
|
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
let start = Math.max(1, current - Math.floor(size / 2));
|
||||||
|
let end = start + size - 1;
|
||||||
|
if (end > total) {
|
||||||
|
end = total;
|
||||||
|
start = end - size + 1;
|
||||||
|
}
|
||||||
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Alle Typen' },
|
||||||
|
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||||||
|
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
|
||||||
|
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
|
||||||
|
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
|
||||||
|
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
|
||||||
|
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
|
||||||
|
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
|
||||||
|
{ value: 'LOGOUT', label: 'Logout' },
|
||||||
|
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||||||
|
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||||||
|
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Alle Stufen' },
|
||||||
|
{ value: 'INFO', label: 'Info' },
|
||||||
|
{ value: 'LOW', label: 'Niedrig' },
|
||||||
|
{ value: 'MEDIUM', label: 'Mittel' },
|
||||||
|
{ value: 'HIGH', label: 'Hoch' },
|
||||||
|
{ value: 'CRITICAL', label: 'Kritisch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function severityClass(s: SecuritySeverity): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
|
||||||
|
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
|
||||||
|
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
|
||||||
|
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityIcon(s: SecuritySeverity): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'CRITICAL': return '🚨';
|
||||||
|
case 'HIGH': return '⚠️';
|
||||||
|
case 'MEDIUM': return '🟡';
|
||||||
|
case 'LOW': return '🟢';
|
||||||
|
default: return 'ℹ️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Monitoring() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
type: '' as SecurityEventType | '',
|
||||||
|
severity: '' as SecuritySeverity | '',
|
||||||
|
search: '',
|
||||||
|
ip: '',
|
||||||
|
});
|
||||||
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
|
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
|
||||||
|
|
||||||
|
const [alertEmail, setAlertEmail] = useState('');
|
||||||
|
const [digestEnabled, setDigestEnabled] = useState(false);
|
||||||
|
|
||||||
|
// Settings laden
|
||||||
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['monitoring-settings'],
|
||||||
|
queryFn: monitoringApi.getSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// States nach Laden synchronisieren (nur initial)
|
||||||
|
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
|
||||||
|
setAlertEmail(settingsData.data.alertEmail);
|
||||||
|
setDigestEnabled(settingsData.data.digestEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events laden
|
||||||
|
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||||
|
queryKey: ['monitoring-events', page, pageSize, filters],
|
||||||
|
queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
|
||||||
|
refetchInterval: 30_000, // alle 30s neu laden
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearEvents = useMutation({
|
||||||
|
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success(res.message || 'Events gelöscht');
|
||||||
|
setShowClearConfirm(false);
|
||||||
|
setClearOlderThanDays('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSettings = useMutation({
|
||||||
|
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Einstellungen gespeichert');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testAlert = useMutation({
|
||||||
|
mutationFn: () => monitoringApi.testAlert(),
|
||||||
|
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
|
||||||
|
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runDigest = useMutation({
|
||||||
|
mutationFn: () => monitoringApi.runDigest(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const r = res.data;
|
||||||
|
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
|
||||||
|
else toast(r?.reason || 'Kein Digest versendet', { icon: 'ℹ️' });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = eventsData?.data || [];
|
||||||
|
const stats = eventsData?.stats;
|
||||||
|
const pagination = eventsData?.pagination;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">
|
||||||
|
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5" /> Alert-Empfänger
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="E-Mail-Adresse für Alerts"
|
||||||
|
type="email"
|
||||||
|
value={alertEmail}
|
||||||
|
onChange={(e) => setAlertEmail(e.target.value)}
|
||||||
|
placeholder="security@deine-firma.de"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<label className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={digestEnabled}
|
||||||
|
onChange={(e) => setDigestEnabled(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
|
||||||
|
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
|
||||||
|
</Button>
|
||||||
|
{settingsData?.data?.lastDigestAt && (
|
||||||
|
<span className="text-xs text-gray-500 self-center">
|
||||||
|
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-gray-600">
|
||||||
|
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
|
||||||
|
E-Mail versendet.<br />
|
||||||
|
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats-Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||||
|
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
|
||||||
|
<Card key={sev}>
|
||||||
|
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
|
||||||
|
{severityIcon(sev)} {sev}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<div className="grid sm:grid-cols-4 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Typ"
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Severity"
|
||||||
|
value={filters.severity}
|
||||||
|
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
|
||||||
|
options={SEVERITY_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Suche (Nachricht/User/Endpoint)"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
|
||||||
|
placeholder="z.B. admin@admin.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="IP-Adresse"
|
||||||
|
value={filters.ip}
|
||||||
|
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
|
||||||
|
placeholder="z.B. 1.2.3.4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">Events</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600">Pro Seite:</label>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
|
||||||
|
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{eventsLoading ? (
|
||||||
|
<div className="text-gray-500 py-4">Lade…</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
|
||||||
|
<th className="px-3 py-2">Severity</th>
|
||||||
|
<th className="px-3 py-2">Typ</th>
|
||||||
|
<th className="px-3 py-2">Nachricht</th>
|
||||||
|
<th className="px-3 py-2">Wer</th>
|
||||||
|
<th className="px-3 py-2">IP</th>
|
||||||
|
<th className="px-3 py-2">Endpoint</th>
|
||||||
|
<th className="px-3 py-2">Alert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((e) => (
|
||||||
|
<tr key={e.id} className="border-t hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||||
|
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
|
||||||
|
{severityIcon(e.severity)} {e.severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
|
||||||
|
<td className="px-3 py-2">{e.message}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–')}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || '–'}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || '–'}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : '–'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mt-4 text-sm">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
title="Erste Seite"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
title="Vorherige Seite"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{paginationWindow(page, pagination.totalPages, 10).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`min-w-[32px] px-2 py-1 rounded border text-sm ${
|
||||||
|
p === page
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
disabled={page >= pagination.totalPages}
|
||||||
|
title="Nächste Seite"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(pagination.totalPages)}
|
||||||
|
disabled={page >= pagination.totalPages}
|
||||||
|
title="Letzte Seite"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clear-Confirm-Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showClearConfirm}
|
||||||
|
onClose={() => setShowClearConfirm(false)}
|
||||||
|
title="Security-Log leeren"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
|
||||||
|
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nur Events älter als (Tage)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={clearOlderThanDays}
|
||||||
|
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||||
|
placeholder="leer = alle löschen"
|
||||||
|
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Beispiel: 30 = nur Events älter als 30 Tage löschen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
|
||||||
|
disabled={clearEvents.isPending}
|
||||||
|
className="!bg-red-600 hover:!bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -657,6 +657,10 @@ export const contractApi = {
|
|||||||
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/follow-up`);
|
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/follow-up`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
createRenewal: async (id: number) => {
|
||||||
|
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/renewal`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
getPassword: async (id: number) => {
|
getPassword: async (id: number) => {
|
||||||
const res = await api.get<ApiResponse<{ password: string }>>(`/contracts/${id}/password`);
|
const res = await api.get<ApiResponse<{ password: string }>>(`/contracts/${id}/password`);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -1426,6 +1430,76 @@ export interface EmailLog {
|
|||||||
sentAt: string;
|
sentAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== MONITORING ====================
|
||||||
|
|
||||||
|
export type SecurityEventType =
|
||||||
|
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||||
|
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||||
|
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
||||||
|
|
||||||
|
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
|
||||||
|
export interface SecurityEvent {
|
||||||
|
id: number;
|
||||||
|
type: SecurityEventType;
|
||||||
|
severity: SecuritySeverity;
|
||||||
|
message: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userId: number | null;
|
||||||
|
customerId: number | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
endpoint: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
alerted: boolean;
|
||||||
|
alertedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringSettings {
|
||||||
|
alertEmail: string;
|
||||||
|
digestEnabled: boolean;
|
||||||
|
lastDigestAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monitoringApi = {
|
||||||
|
getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.limit) q.set('limit', String(params.limit));
|
||||||
|
if (params?.type) q.set('type', params.type);
|
||||||
|
if (params?.severity) q.set('severity', params.severity);
|
||||||
|
if (params?.search) q.set('search', params.search);
|
||||||
|
if (params?.ip) q.set('ip', params.ip);
|
||||||
|
if (params?.since) q.set('since', params.since);
|
||||||
|
const res = await api.get<ApiResponse<SecurityEvent[]> & {
|
||||||
|
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||||
|
stats: { byType: Record<string, number>; bySeverity: Record<string, number> };
|
||||||
|
}>(`/monitoring/events?${q}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
getSettings: async () => {
|
||||||
|
const res = await api.get<ApiResponse<MonitoringSettings>>('/monitoring/settings');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => {
|
||||||
|
const res = await api.put<ApiResponse<void>>('/monitoring/settings', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
testAlert: async () => {
|
||||||
|
const res = await api.post<ApiResponse<void>>('/monitoring/test-alert');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
runDigest: async () => {
|
||||||
|
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
clearEvents: async (olderThanDays?: number) => {
|
||||||
|
const q = olderThanDays ? `?olderThanDays=${olderThanDays}` : '';
|
||||||
|
const res = await api.delete<ApiResponse<{ deletedCount: number }>>(`/monitoring/events${q}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const emailLogApi = {
|
export const emailLogApi = {
|
||||||
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||||||
*
|
*
|
||||||
* `/api/uploads/*` läuft hinter authenticate-Middleware, aber <a href> und
|
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||||||
* window.open senden keinen Authorization-Header. Darum hängen wir das JWT
|
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||||||
* als Query-Parameter an. Die authenticate-Middleware akzeptiert
|
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||||||
* `?token=<jwt>` neben dem Header.
|
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||||||
*
|
*
|
||||||
* Trade-off: Tokens in URLs landen potenziell in Logs/Referrer. Für eine
|
* <a href> und window.open senden keinen Authorization-Header, daher
|
||||||
* saubere Lösung (kurzlebige Download-Tokens) wäre ein separater Endpoint
|
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||||||
* nötig – TODO für v1.1.
|
*
|
||||||
|
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
|
||||||
|
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
|
||||||
|
* wäre v1.1-Item.
|
||||||
*/
|
*/
|
||||||
export function fileUrl(path: string | null | undefined): string {
|
export function fileUrl(path: string | null | undefined): string {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const base = `/api${path.startsWith('/') ? path : '/' + path}`;
|
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||||
|
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
||||||
if (!token) return base;
|
if (!token) return base;
|
||||||
const separator = base.includes('?') ? '&' : '?';
|
return `${base}&token=${encodeURIComponent(token)}`;
|
||||||
return `${base}${separator}token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user