Compare commits
169 Commits
2699654631
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c3b0dc95 | |||
| 83cd737e81 | |||
| a95aa384a2 | |||
| 9cf8c505af | |||
| 65ec07e274 | |||
| 8e48d3b432 | |||
| adc3b70492 | |||
| bf7afdd9a6 | |||
| b3a6620da6 | |||
| 8ee5c9b07a | |||
| 37df8c0c4a | |||
| 95541e8ac4 | |||
| 6ae815393e | |||
| 06c427ee39 | |||
| 2cb6f172c9 | |||
| 0f2dc44e45 | |||
| b87a2a3d08 | |||
| 48fe69cdab | |||
| 373fab8e83 | |||
| 5ffd1a4d2c | |||
| 7b9a1981a7 | |||
| 3dda83314a | |||
| 3e1fc3eab2 | |||
| cf8c6c84c2 | |||
| d545790a69 | |||
| ef238b0145 | |||
| 7b6b586033 | |||
| 3dea381983 | |||
| 28c91759df | |||
| c744eebfa3 | |||
| 956bc394b8 | |||
| 69b9a35674 | |||
| a982795388 | |||
| 38c2d82c02 | |||
| 75c833500e | |||
| a7d12b8540 | |||
| 8534be22d0 | |||
| f0c97cd46d | |||
| 8a5ffbb563 | |||
| 6af1a4bbd4 | |||
| 92d2e62e79 | |||
| 08310ac302 | |||
| 72f7a9dbdb | |||
| c5dc271759 | |||
| 1451e362ff | |||
| 8188d17c87 | |||
| c4e62f0f50 | |||
| 9830ac29a5 | |||
| 0943f11999 | |||
| e2cd26a29e | |||
| f6df97226d | |||
| 185b38dc55 | |||
| 51eb12b414 | |||
| c2ebc7cf1e | |||
| b4be3cebfb | |||
| 083913cadb | |||
| 4c0cc90734 | |||
| 70e97d3ece | |||
| 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,94 @@
|
|||||||
|
# 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
|
||||||
|
# Wird sowohl für Access- als auch Refresh-Token verwendet.
|
||||||
|
JWT_SECRET=change-this-to-a-very-long-random-secret-please-rotate-before-production
|
||||||
|
|
||||||
|
# Access-/Refresh-Token-Lifetimes
|
||||||
|
# - Access-Token: kurzlebig, lebt nur im Browser-Memory (XSS-Schutz)
|
||||||
|
# - Refresh-Token: lang, im httpOnly-Cookie (JS-unzugänglich)
|
||||||
|
# Wenn der Access abläuft, holt das Frontend transparent einen neuen über
|
||||||
|
# /api/auth/refresh – User merkt nichts. Logout invalidiert beide sofort.
|
||||||
|
JWT_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_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
|
||||||
|
|
||||||
|
# SSRF-Schutz: private IP-Ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
|
||||||
|
# 192.168.0.0/16, ::1, fc00::/7, localhost) bei Provider/SMTP-Test-Connection
|
||||||
|
# blockieren. Default `false` damit On-Prem-Setups Plesk/Dovecot/Postfix auf
|
||||||
|
# 127.0.0.1 oder im internen Netz nutzen können. Für Cloud-Deployments
|
||||||
|
# (öffentlich erreichbares Backend) auf `true` setzen, sonst kann ein
|
||||||
|
# eingeloggter Admin via /email-providers/test-connection interne Services
|
||||||
|
# anpingen. Cloud-Metadata-Endpoints (169.254.169.254 etc.) sind UNABHÄNGIG
|
||||||
|
# vom Flag immer geblockt.
|
||||||
|
SSRF_BLOCK_PRIVATE_IPS=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
|
||||||
@@ -29,38 +41,70 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Backend**: Node.js, Express 4.x, TypeScript
|
- **Backend**: Node.js, Express 4.x, TypeScript
|
||||||
- **Datenbank**: MariaDB
|
- **Datenbank**: MariaDB
|
||||||
- **ORM**: Prisma
|
- **ORM**: Prisma
|
||||||
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle
|
- **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
|
||||||
|
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
|
||||||
|
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
|
||||||
|
|
||||||
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
|
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
|
||||||
> - 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.
|
||||||
|
|
||||||
@@ -82,9 +126,14 @@ Die `.env`-Datei sollte folgende Werte enthalten:
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
||||||
|
|
||||||
# JWT
|
# JWT – Access-/Refresh-Token-Pattern (SPA-Standard)
|
||||||
|
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
|
||||||
|
# Refresh-Token (httpOnly-Cookie, lang)
|
||||||
|
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
|
||||||
|
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
|
||||||
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
||||||
JWT_EXPIRES_IN="7d"
|
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
|
||||||
|
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
|
||||||
|
|
||||||
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
@@ -140,6 +189,197 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment-Modus: On-Prem vs. Cloud
|
||||||
|
|
||||||
|
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
|
||||||
|
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
|
||||||
|
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
|
||||||
|
SSRF-Schalter:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
|
||||||
|
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
|
||||||
|
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
|
||||||
|
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
|
||||||
|
SSRF_BLOCK_PRIVATE_IPS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
||||||
|
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
||||||
|
AWS/GCP/Azure-IMDS-Diebstahl.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
|
||||||
|
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` – wenn Frontend
|
||||||
|
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
|
||||||
|
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
|
||||||
|
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
|
||||||
|
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
|
||||||
|
auf derselben Domain via Proxy-Path.
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
### ⚠️ Wichtig: gzip für `/api/*` am Reverse-Proxy deaktivieren (BREACH-Schutz)
|
||||||
|
|
||||||
|
Wenn ein TLS-Reverse-Proxy (Nginx Proxy Manager, Caddy, eigener Nginx, …) HTTPS
|
||||||
|
terminiert und Antworten gzip-komprimiert, ist die **BREACH-Attacke** (CVE-2013-3587)
|
||||||
|
theoretisch möglich: aus der gzip-komprimierten Response-Größe könnten unter
|
||||||
|
ungünstigen Umständen Secrets erraten werden. Auch wenn unsere JWT-basierte SPA
|
||||||
|
das Risiko praktisch klein hält (keine reflektierten Secrets im Response-Body),
|
||||||
|
geht ein Penetration-Test mit testssl trotzdem auf „medium – Ausnutzbar: Ja".
|
||||||
|
|
||||||
|
**Lösung:** gzip-Komprimierung nur für statische Frontend-Assets erlauben, für
|
||||||
|
`/api/*` deaktivieren. Statische Bundles bleiben damit performant ausgeliefert,
|
||||||
|
JSON-API-Responses werden ohne Kompression gesendet → BREACH ist dort kein
|
||||||
|
Einfallstor mehr.
|
||||||
|
|
||||||
|
**Nginx Proxy Manager (NPM):**
|
||||||
|
1. Proxy-Hosts → den CRM-Host → **Edit**
|
||||||
|
2. Tab **Custom Locations** → **„Add location"**
|
||||||
|
3. **Define location:** `/api/`
|
||||||
|
4. **Scheme:** `http`, **Forward Hostname/IP:** wie im Haupt-Host
|
||||||
|
(z.B. `172.0.2.39`), **Forward Port:** `3010`
|
||||||
|
5. Zahnrad rechts an der Location → erweiterte Config eintragen:
|
||||||
|
```nginx
|
||||||
|
gzip off;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Information-Disclosure-Header weg (Pentest-Hygiene):
|
||||||
|
more_clear_headers Server X-Served-By;
|
||||||
|
```
|
||||||
|
6. **Save** (Location), **Save** (Proxy-Host)
|
||||||
|
|
||||||
|
> Der `more_clear_headers`-Befehl kommt aus dem `headers-more`-Modul, das
|
||||||
|
> bei NPM standardmäßig dabei ist. Damit verschwinden die Banner
|
||||||
|
> `Server: openresty` und `x-served-by: …` aus den Responses – Pentest-
|
||||||
|
> Tools können den eingesetzten Webserver nicht mehr direkt aus dem Header
|
||||||
|
> ablesen. Wer das auch auf der Hauptlocation will, kann denselben Eintrag
|
||||||
|
> zusätzlich im **Advanced**-Tab des Proxy-Hosts setzen.
|
||||||
|
|
||||||
|
**Plain Nginx** (falls eigener Nginx statt NPM):
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
gzip off;
|
||||||
|
proxy_pass http://backend:3010;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
more_clear_headers Server X-Served-By; # braucht headers-more-Modul
|
||||||
|
}
|
||||||
|
# Optional global im server { … }-Block:
|
||||||
|
server_tokens off;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verifikation:**
|
||||||
|
```bash
|
||||||
|
# 1) gzip ist für /api/ deaktiviert (sollte leer sein)
|
||||||
|
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/api/health \
|
||||||
|
| grep -i content-encoding
|
||||||
|
|
||||||
|
# 2) Server-/x-served-by-Banner sind weg (sollte leer sein)
|
||||||
|
curl -sI https://kundencenter.deine-domain.de/api/health \
|
||||||
|
| grep -iE '^(server|x-served-by):'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Was mit gzip auf `/` (SPA-HTML) ist
|
||||||
|
|
||||||
|
Pentest-Tools wie `testssl` melden BREACH **trotzdem weiter** für die
|
||||||
|
Root-URL `/`, weil die SPA-`index.html` bewusst weiter gzip-komprimiert
|
||||||
|
ausgeliefert wird (Performance: 50 KB → ~10 KB). Bei OpenCRM ist der
|
||||||
|
Angriff dort nicht ausnutzbar:
|
||||||
|
|
||||||
|
- Die `/`-Response ist die statische `index.html` aus dem Vite-Build
|
||||||
|
- Sie reflektiert **keinen user-controlled Input**
|
||||||
|
- Sie enthält **keine Secrets** (JWT-Access ist im `Authorization`-Header,
|
||||||
|
Refresh-Token im httpOnly-Cookie – beides nicht im HTML-Body)
|
||||||
|
|
||||||
|
Ohne Secret-im-Body und ohne Input-Reflektion hat BREACH keinen Hebel.
|
||||||
|
|
||||||
|
##### Wer den Audit-Marker trotzdem weg haben will
|
||||||
|
|
||||||
|
Wichtig: nicht einfach eine Custom-Location für `/` mit `gzip off`
|
||||||
|
anlegen – das wäre ein **prefix-Match** und würde **alle** Pfade
|
||||||
|
außer `/api/*` betreffen, also auch `/assets/*.{js,css}`. Das JS-Bundle
|
||||||
|
käme dann unkomprimiert (~500 KB statt ~150 KB) → spürbarer
|
||||||
|
Performance-Verlust für nichts.
|
||||||
|
|
||||||
|
Sauber ist eine **exact-Match-Location** (`location = /`) – die fängt
|
||||||
|
nur die Root-URL ohne weitere Pfad-Komponente:
|
||||||
|
|
||||||
|
**Variante A** – Custom Location im NPM-UI (falls `= /` im
|
||||||
|
„Define location"-Feld akzeptiert wird):
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Define location | `= /` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname/IP | wie im Haupt-Host |
|
||||||
|
| Forward Port | `3010` |
|
||||||
|
|
||||||
|
Im Zahnrad-Edit der Location:
|
||||||
|
```nginx
|
||||||
|
gzip off;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Information-Disclosure-Header weg (Pentest-Hygiene):
|
||||||
|
more_clear_headers Server X-Served-By;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variante B** – wenn das NPM-UI das `=` nicht akzeptiert, dieselbe
|
||||||
|
Logik im **Advanced**-Tab des Proxy-Hosts:
|
||||||
|
```nginx
|
||||||
|
location = / {
|
||||||
|
gzip off;
|
||||||
|
proxy_pass $forward_scheme://$server:$port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
more_clear_headers Server X-Served-By;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifikation – `/` ohne gzip, `/assets/*` aber weiter mit:
|
||||||
|
```bash
|
||||||
|
# Root: kein Content-Encoding mehr
|
||||||
|
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/ \
|
||||||
|
| grep -i content-encoding
|
||||||
|
|
||||||
|
# /assets/<file>.js: weiterhin gzip (Performance bleibt erhalten)
|
||||||
|
JS=$(curl -s https://kundencenter.deine-domain.de/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
|
||||||
|
curl -sI -H 'Accept-Encoding: gzip' "https://kundencenter.deine-domain.de/$JS" \
|
||||||
|
| grep -i content-encoding
|
||||||
|
```
|
||||||
|
|
||||||
|
Kostet 40 KB extra pro Tab-Reload – aber dafür ist auch der letzte
|
||||||
|
BREACH-Marker weg und Pentest-Reports landen auf 0×MEDIUM.
|
||||||
|
|
||||||
## 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 +1231,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 +1241,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 +1361,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,9 +1,17 @@
|
|||||||
|
# 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"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
JWT_EXPIRES_IN="7d"
|
# Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
|
||||||
|
JWT_EXPIRES_IN="15m"
|
||||||
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
|
||||||
# Encryption (for portal credentials)
|
# Encryption (for portal credentials)
|
||||||
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
||||||
|
|||||||
+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,77 @@
|
|||||||
|
# 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
|
||||||
|
# src/ mitkopieren, damit prisma/*.ts-Wartungsskripte (cleanup, reset-admin-
|
||||||
|
# password etc.) auch im Production-Container via `npx tsx` laufen können –
|
||||||
|
# die importieren über '../src/lib/prisma.js' den shared Prisma-Client.
|
||||||
|
# Server selbst läuft weiter aus dist/.
|
||||||
|
COPY --from=backend-builder /build/backend/src ./src
|
||||||
|
COPY backend/tsconfig.json ./tsconfig.json
|
||||||
|
|
||||||
|
# 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
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# Permissions + Rollen-Sync: Stellt sicher, dass nachträglich hinzugefügte
|
||||||
|
# Permissions (z.B. audit:read auf der DSGVO-Rolle) auch auf bestehenden
|
||||||
|
# DBs ankommen. Seed läuft NICHT auf nicht-leeren DBs, daher würden alte
|
||||||
|
# Installationen sonst mit unvollständigen Role-Perms laufen. Idempotent,
|
||||||
|
# fasst keine Stammdaten / User / Verträge an.
|
||||||
|
echo "[entrypoint] Rollen + Permissions synchronisieren…"
|
||||||
|
npx tsx prisma/sync-roles.ts \
|
||||||
|
|| echo "[entrypoint] Role-Sync fehlgeschlagen – nicht kritisch"
|
||||||
|
|
||||||
|
# Datenbereinigung: XSS-Strings aus Customer/User-Stringfeldern strippen,
|
||||||
|
# nicht-whitelisted AppSettings entfernen, Pentest-Marker melden (Default
|
||||||
|
# nur warnen; CLEANUP_PURGE_PENTEST=true löscht markierte Records).
|
||||||
|
# Idempotent – läuft bei jedem Container-Start ohne Risiko.
|
||||||
|
echo "[entrypoint] Datenbereinigung läuft…"
|
||||||
|
npx tsx prisma/cleanup-xss-and-mass-assignment.ts \
|
||||||
|
|| echo "[entrypoint] Cleanup übersprungen / fehlgeschlagen – nicht kritisch"
|
||||||
|
|
||||||
|
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
+201
-101
@@ -1,19 +1,22 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"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 +29,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 +46,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 +56,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -69,7 +71,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -85,7 +86,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -101,7 +101,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -117,7 +116,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -133,7 +131,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -149,7 +146,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -165,7 +161,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -181,7 +176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -197,7 +191,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -213,7 +206,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -229,7 +221,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -245,7 +236,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -261,7 +251,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -277,7 +266,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -293,7 +281,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -309,7 +296,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -325,7 +311,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -341,7 +326,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -357,7 +341,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -373,7 +356,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -389,7 +371,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -405,7 +386,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -421,7 +401,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -437,7 +416,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -453,7 +431,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -511,7 +488,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",
|
||||||
@@ -632,7 +610,6 @@
|
|||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -642,11 +619,19 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cors": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -660,7 +645,6 @@
|
|||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^4.17.33",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
@@ -672,7 +656,6 @@
|
|||||||
"version": "4.19.8",
|
"version": "4.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"@types/qs": "*",
|
"@types/qs": "*",
|
||||||
@@ -683,8 +666,7 @@
|
|||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/jsonwebtoken": {
|
"node_modules/@types/jsonwebtoken": {
|
||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
@@ -721,8 +703,7 @@
|
|||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@@ -743,7 +724,6 @@
|
|||||||
"version": "22.19.7",
|
"version": "22.19.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -776,14 +756,12 @@
|
|||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/readdir-glob": {
|
"node_modules/@types/readdir-glob": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
@@ -798,7 +776,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -807,7 +784,6 @@
|
|||||||
"version": "1.15.10",
|
"version": "1.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -818,7 +794,6 @@
|
|||||||
"version": "0.17.6",
|
"version": "0.17.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -986,6 +961,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 +1045,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"
|
||||||
}
|
}
|
||||||
@@ -1274,6 +1251,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -1460,6 +1456,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 +1577,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 +1803,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 +1859,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 +2023,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 +2065,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 +2278,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 +2324,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 +2356,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 +2440,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 +2560,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 +2630,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 +2680,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 +2705,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 +2713,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 +2766,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 +2790,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 +2807,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 +2860,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 +2875,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 +2884,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 +2916,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 +3097,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 +3117,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 +3139,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 +3293,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 +3331,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 +3381,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"
|
||||||
}
|
}
|
||||||
@@ -3291,8 +3392,7 @@
|
|||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unicode-properties": {
|
"node_modules/unicode-properties": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -20,11 +21,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"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 +41,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 +58,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,351 @@
|
|||||||
|
/**
|
||||||
|
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
|
||||||
|
*
|
||||||
|
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
|
||||||
|
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
|
||||||
|
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
|
||||||
|
*
|
||||||
|
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
|
||||||
|
* mehrfach aufrufbar.
|
||||||
|
*/
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { stripHtml } from '../src/utils/sanitize.js';
|
||||||
|
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||||||
|
|
||||||
|
const CUSTOMER_STRING_FIELDS = [
|
||||||
|
'salutation', 'firstName', 'lastName', 'companyName',
|
||||||
|
'birthPlace', 'email', 'phone', 'mobile',
|
||||||
|
'taxNumber', 'commercialRegisterNumber', 'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_STRING_FIELDS = [
|
||||||
|
'firstName', 'lastName', 'email',
|
||||||
|
'whatsappNumber', 'telegramUsername', 'signalNumber',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function cleanupXss() {
|
||||||
|
const customers = await prisma.customer.findMany();
|
||||||
|
let touched = 0;
|
||||||
|
for (const c of customers) {
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
for (const field of CUSTOMER_STRING_FIELDS) {
|
||||||
|
const v = (c as any)[field];
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const cleaned = stripHtml(v) as string;
|
||||||
|
if (cleaned !== v) updates[field] = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||||||
|
await prisma.customer.update({ where: { id: c.id }, data: updates });
|
||||||
|
touched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → Customer bereinigt: ${touched}`);
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
let userTouched = 0;
|
||||||
|
for (const u of users) {
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
for (const field of USER_STRING_FIELDS) {
|
||||||
|
const v = (u as any)[field];
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const cleaned = stripHtml(v) as string;
|
||||||
|
if (cleaned !== v) updates[field] = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
|
||||||
|
await prisma.user.update({ where: { id: u.id }, data: updates });
|
||||||
|
userTouched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → User bereinigt: ${userTouched}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
|
||||||
|
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
|
||||||
|
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
|
||||||
|
const HTML_ALLOWED_SETTING_KEYS = new Set([
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stripHtmlString(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||||
|
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
|
||||||
|
// auf 'unknown' normalisiert. Pentest 2026-05-20.
|
||||||
|
const ALLOWED_CONSENT_SOURCES = new Set([
|
||||||
|
'portal',
|
||||||
|
'public-link',
|
||||||
|
'telefon',
|
||||||
|
'papier',
|
||||||
|
'email',
|
||||||
|
'crm-backend',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||||
|
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
||||||
|
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
||||||
|
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
||||||
|
// raus (Pentest 2026-05-20 LOW 27.1).
|
||||||
|
function isValidDocumentPath(v: string | null | undefined): boolean {
|
||||||
|
if (!v) return true; // null/leer ist OK
|
||||||
|
if (v.includes('..')) return false;
|
||||||
|
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||||||
|
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
|
||||||
|
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
|
||||||
|
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupConsents() {
|
||||||
|
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||||
|
// source: Whitelist erzwingen.
|
||||||
|
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
|
||||||
|
let versionStripped = 0;
|
||||||
|
let pathNulled = 0;
|
||||||
|
let sourceFixed = 0;
|
||||||
|
const consents = await prisma.customerConsent.findMany({
|
||||||
|
select: { id: true, source: true, documentPath: true, version: true },
|
||||||
|
});
|
||||||
|
for (const c of consents) {
|
||||||
|
const data: Record<string, string | null> = {};
|
||||||
|
if (c.version && c.version !== stripHtmlString(c.version)) {
|
||||||
|
data.version = stripHtmlString(c.version);
|
||||||
|
versionStripped++;
|
||||||
|
}
|
||||||
|
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
|
||||||
|
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
|
||||||
|
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
|
||||||
|
data.documentPath = null;
|
||||||
|
pathNulled++;
|
||||||
|
}
|
||||||
|
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
|
||||||
|
data.source = 'unknown';
|
||||||
|
sourceFixed++;
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
await prisma.customerConsent.update({ where: { id: c.id }, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
|
||||||
|
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
|
||||||
|
// server-seitig vom multer-Upload erzeugt – falls dort doch mal ein
|
||||||
|
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
|
||||||
|
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
|
||||||
|
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
|
||||||
|
// löschen aber nicht (Records müssten manuell angeschaut werden).
|
||||||
|
async function cleanupDocumentPaths() {
|
||||||
|
const findings: { table: string; id: number; value: string }[] = [];
|
||||||
|
|
||||||
|
const optional: Array<{
|
||||||
|
label: string;
|
||||||
|
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
|
||||||
|
update: (id: number) => Promise<unknown>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: 'BankCard',
|
||||||
|
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
|
||||||
|
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'IdentityDocument',
|
||||||
|
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
|
||||||
|
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Invoice',
|
||||||
|
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
|
||||||
|
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RepresentativeAuthorization',
|
||||||
|
fetch: () => prisma.representativeAuthorization.findMany({
|
||||||
|
select: { id: true, documentPath: true },
|
||||||
|
}),
|
||||||
|
update: (id) => prisma.representativeAuthorization.update({
|
||||||
|
where: { id }, data: { documentPath: null },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let nulled = 0;
|
||||||
|
for (const t of optional) {
|
||||||
|
const rows = await t.fetch();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
|
||||||
|
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
|
||||||
|
await t.update(r.id);
|
||||||
|
nulled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
|
||||||
|
const contractDocs = await prisma.contractDocument.findMany({
|
||||||
|
select: { id: true, documentPath: true },
|
||||||
|
});
|
||||||
|
let contractDocsDirty = 0;
|
||||||
|
for (const d of contractDocs) {
|
||||||
|
if (!isValidDocumentPath(d.documentPath)) {
|
||||||
|
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
|
||||||
|
contractDocsDirty++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
|
||||||
|
for (const f of findings.slice(0, 10)) {
|
||||||
|
console.log(` [${f.table}#${f.id}] "${f.value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportOrphanedUsers() {
|
||||||
|
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
|
||||||
|
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
|
||||||
|
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
|
||||||
|
// Spezial-User treffen) – nur warnen.
|
||||||
|
const orphans = await prisma.user.findMany({
|
||||||
|
where: { roles: { none: {} } },
|
||||||
|
select: { id: true, email: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (orphans.length === 0) {
|
||||||
|
console.log(' → Keine User ohne Rollenzuordnung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
|
||||||
|
for (const u of orphans.slice(0, 10)) {
|
||||||
|
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
|
||||||
|
}
|
||||||
|
console.log(' → Rolle zuweisen oder User löschen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupAppSettings() {
|
||||||
|
const settings = await prisma.appSetting.findMany();
|
||||||
|
const removed: string[] = [];
|
||||||
|
let stripped = 0;
|
||||||
|
for (const s of settings) {
|
||||||
|
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||||
|
removed.push(s.key);
|
||||||
|
await prisma.appSetting.delete({ where: { key: s.key } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||||
|
const cleaned = stripHtmlString(s.value);
|
||||||
|
if (cleaned !== s.value) {
|
||||||
|
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
|
||||||
|
stripped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
|
||||||
|
if (stripped > 0) {
|
||||||
|
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
|
||||||
|
// gefasst – legitime Kunden mit "Hacker" als Nachnamen sollen nicht
|
||||||
|
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
|
||||||
|
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
|
||||||
|
// `hacker@familie-hacker.de` o.ä. fängen.
|
||||||
|
const PENTEST_MARKERS = [
|
||||||
|
/@evil\./i,
|
||||||
|
/^attacker@/i,
|
||||||
|
/^pentest@/i,
|
||||||
|
/<script\b/i, // unverwechselbarer XSS-Marker
|
||||||
|
/\bonerror\s*=/i, // <img onerror=…>
|
||||||
|
/javascript:/i, // javascript:-URL
|
||||||
|
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
|
||||||
|
/\.\.\/.*etc\/passwd/i, // Path-Traversal
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksLikePentestData(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
return PENTEST_MARKERS.some((re) => re.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrPurgePentestRecords() {
|
||||||
|
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
|
||||||
|
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
|
||||||
|
|
||||||
|
const customers = await prisma.customer.findMany();
|
||||||
|
for (const c of customers) {
|
||||||
|
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
|
||||||
|
if (looksLikePentestData((c as any)[f])) {
|
||||||
|
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
for (const u of users) {
|
||||||
|
for (const f of ['email', 'firstName', 'lastName']) {
|
||||||
|
if (looksLikePentestData((u as any)[f])) {
|
||||||
|
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suspect.length === 0) {
|
||||||
|
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` → ${suspect.length} verdächtige Records (Pentest-Marker):`);
|
||||||
|
for (const s of suspect) {
|
||||||
|
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!purge) {
|
||||||
|
console.log(' ℹ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
|
||||||
|
console.log(' oder Records manuell über adminer entfernen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of suspect) {
|
||||||
|
if (s.kind === 'Customer') {
|
||||||
|
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
|
||||||
|
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||||||
|
});
|
||||||
|
} else if (s.kind === 'User') {
|
||||||
|
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
|
||||||
|
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → ${suspect.length} verdächtige Records gelöscht.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
|
||||||
|
await cleanupXss();
|
||||||
|
await cleanupAppSettings();
|
||||||
|
await cleanupConsents();
|
||||||
|
await cleanupDocumentPaths();
|
||||||
|
await reportOrphanedUsers();
|
||||||
|
await findOrPurgePentestRecords();
|
||||||
|
console.log('=== Fertig. ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Cleanup fehlgeschlagen:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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`);
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- IF NOT EXISTS: macht das Hochziehen auf prod-DBs sicher, die das Feld
|
||||||
|
-- über `prisma db push` schon erhalten haben (vor dem Migrations-Workflow).
|
||||||
|
-- MariaDB unterstützt das seit 10.0.2, MySQL 8 ebenfalls.
|
||||||
|
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
|
||||||
|
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
|
||||||
|
-- jemand vorher manuell `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `BackupLog` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
|
||||||
|
`backupName` VARCHAR(191) NULL,
|
||||||
|
`success` BOOLEAN NOT NULL,
|
||||||
|
`durationMs` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`summary` TEXT NOT NULL,
|
||||||
|
`fullLog` LONGTEXT NOT NULL,
|
||||||
|
`userId` INTEGER NULL,
|
||||||
|
`userEmail` VARCHAR(191) NULL,
|
||||||
|
`ipAddress` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
|
||||||
|
INDEX `BackupLog_createdAt_idx`(`createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB.
|
||||||
|
* Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein
|
||||||
|
* Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte
|
||||||
|
* E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift).
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* npx tsx prisma/reset-admin-password.ts <email> # generiert PW
|
||||||
|
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # eigenes PW
|
||||||
|
*
|
||||||
|
* Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions
|
||||||
|
* dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung
|
||||||
|
* gestohlener Tokens).
|
||||||
|
*/
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js';
|
||||||
|
|
||||||
|
const BCRYPT_COST = 12;
|
||||||
|
|
||||||
|
function generateRescuePassword(): string {
|
||||||
|
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const digits = '23456789';
|
||||||
|
const special = '!@#$%&*+=?';
|
||||||
|
const all = upper + lower + digits + special;
|
||||||
|
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
|
||||||
|
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
|
||||||
|
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = process.argv[2];
|
||||||
|
const providedPw = process.argv[3];
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.error('Aufruf: npx tsx prisma/reset-admin-password.ts <email> [passwort]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true, email: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
console.error(`User "${email}" nicht gefunden.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plain: string;
|
||||||
|
if (providedPw) {
|
||||||
|
const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||||
|
if (!c.ok) {
|
||||||
|
console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:');
|
||||||
|
for (const e of c.errors) console.error(' - ' + e);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
plain = providedPw;
|
||||||
|
} else {
|
||||||
|
plain = generateRescuePassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(plain, BCRYPT_COST);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
password: hash,
|
||||||
|
passwordResetToken: null,
|
||||||
|
passwordResetExpiresAt: null,
|
||||||
|
tokenInvalidatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('========================================================');
|
||||||
|
console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`);
|
||||||
|
console.log(` Neues Passwort: ${plain}`);
|
||||||
|
console.log(' ⚠️ Wird hier EINMAL ausgegeben – sofort kopieren!');
|
||||||
|
console.log(' Alle bestehenden Sessions wurden invalidiert.');
|
||||||
|
console.log('========================================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Reset fehlgeschlagen:', e);
|
||||||
|
process.exit(99);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -172,6 +172,10 @@ model Customer {
|
|||||||
portalPasswordResetExpiresAt DateTime?
|
portalPasswordResetExpiresAt DateTime?
|
||||||
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
|
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
|
||||||
portalTokenInvalidatedAt DateTime?
|
portalTokenInvalidatedAt DateTime?
|
||||||
|
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
|
||||||
|
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
|
||||||
|
// Frontend in Force-Change-Password-Flow geleitet.
|
||||||
|
portalPasswordMustChange Boolean @default(false)
|
||||||
|
|
||||||
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
||||||
lastBirthdayGreetingYear Int?
|
lastBirthdayGreetingYear Int?
|
||||||
@@ -1113,3 +1117,80 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackupOperation {
|
||||||
|
CREATE
|
||||||
|
RESTORE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistiertes Log für Backup-Vorgänge.
|
||||||
|
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
|
||||||
|
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
|
||||||
|
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
|
||||||
|
// geschrieben.
|
||||||
|
model BackupLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
operation BackupOperation
|
||||||
|
backupName String?
|
||||||
|
success Boolean
|
||||||
|
durationMs Int @default(0)
|
||||||
|
summary String @db.Text
|
||||||
|
fullLog String @db.LongText
|
||||||
|
userId Int?
|
||||||
|
userEmail String?
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([operation, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|||||||
+48
-3
@@ -221,8 +221,41 @@ async function main() {
|
|||||||
|
|
||||||
console.log('Roles created');
|
console.log('Roles created');
|
||||||
|
|
||||||
// Create admin user
|
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||||
|
// Komplexitätspolicy). Stattdessen:
|
||||||
|
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
|
||||||
|
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
|
||||||
|
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
|
||||||
|
// Passwort-vergessen-Flow nutzen.
|
||||||
|
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
|
||||||
|
function generateInitialPassword(): string {
|
||||||
|
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const digits = '23456789';
|
||||||
|
const special = '!@#$%&*+=?';
|
||||||
|
const all = upper + lower + digits + special;
|
||||||
|
// Kryptografisch sichere Auswahl – Math.random() ist vorhersagbar
|
||||||
|
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
|
||||||
|
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||||
|
// mind. einen aus jeder Klasse + Rest zufällig
|
||||||
|
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
|
||||||
|
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
|
||||||
|
// Mitarbeiter-Schwellwert (Pentest Runde 13).
|
||||||
|
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
|
||||||
|
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = crypto.randomInt(0, i + 1);
|
||||||
|
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPassword = process.env.SEED_ADMIN_PASSWORD;
|
||||||
|
const adminPlainPassword = envPassword && envPassword.length >= 25
|
||||||
|
? envPassword
|
||||||
|
: generateInitialPassword();
|
||||||
|
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||||
|
|
||||||
const adminUser = await prisma.user.upsert({
|
const adminUser = await prisma.user.upsert({
|
||||||
where: { email: 'admin@admin.com' },
|
where: { email: 'admin@admin.com' },
|
||||||
@@ -238,7 +271,19 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Admin user created: admin@admin.com / admin');
|
console.log('========================================================');
|
||||||
|
console.log(' Admin-User: admin@admin.com');
|
||||||
|
if (envPassword && envPassword.length >= 25) {
|
||||||
|
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
|
||||||
|
} else {
|
||||||
|
if (envPassword && envPassword.length < 25) {
|
||||||
|
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
|
||||||
|
}
|
||||||
|
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||||
|
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||||
|
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||||
|
}
|
||||||
|
console.log('========================================================');
|
||||||
|
|
||||||
// Create some sales platforms
|
// Create some sales platforms
|
||||||
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
||||||
|
*
|
||||||
|
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
||||||
|
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
||||||
|
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
||||||
|
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
||||||
|
*
|
||||||
|
* Dieses Skript synchronisiert ausschließlich:
|
||||||
|
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
||||||
|
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
||||||
|
* Mitarbeiter (Nur-Lesen), Kunde)
|
||||||
|
*
|
||||||
|
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
||||||
|
* laufenden Prod-DBs sicher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
customers: ['create', 'read', 'update', 'delete'],
|
||||||
|
contracts: ['create', 'read', 'update', 'delete'],
|
||||||
|
users: ['create', 'read', 'update', 'delete'],
|
||||||
|
platforms: ['create', 'read', 'update', 'delete'],
|
||||||
|
providers: ['create', 'read', 'update', 'delete'],
|
||||||
|
tariffs: ['create', 'read', 'update', 'delete'],
|
||||||
|
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
||||||
|
'contract-durations': ['create', 'read', 'update', 'delete'],
|
||||||
|
'contract-categories': ['create', 'read', 'update', 'delete'],
|
||||||
|
'email-providers': ['create', 'read', 'update', 'delete'],
|
||||||
|
settings: ['read', 'update'],
|
||||||
|
developer: ['access'],
|
||||||
|
emails: ['delete'],
|
||||||
|
audit: ['read', 'export', 'admin'],
|
||||||
|
gdpr: ['export', 'delete', 'admin'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
||||||
|
const existing = await prisma.rolePermission.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { permissionId: true },
|
||||||
|
});
|
||||||
|
const existingIds = new Set(existing.map((e) => e.permissionId));
|
||||||
|
const targetIds = new Set(permissionIds);
|
||||||
|
|
||||||
|
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const excess = existing
|
||||||
|
.filter((e) => !targetIds.has(e.permissionId))
|
||||||
|
.map((e) => e.permissionId);
|
||||||
|
if (excess.length > 0) {
|
||||||
|
await prisma.rolePermission.deleteMany({
|
||||||
|
where: { roleId, permissionId: { in: excess } },
|
||||||
|
});
|
||||||
|
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[sync-roles] Permissions-Katalog upserten…');
|
||||||
|
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
||||||
|
for (const action of actions) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { resource_action: { resource, action } },
|
||||||
|
update: {},
|
||||||
|
create: { resource, action },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allPermissions = await prisma.permission.findMany();
|
||||||
|
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
||||||
|
|
||||||
|
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
||||||
|
// sind separate hidden roles, über Checkboxen zugewiesen)
|
||||||
|
const adminPermIds = allPermissions
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
!(p.resource === 'developer' && p.action === 'access') &&
|
||||||
|
p.resource !== 'audit' &&
|
||||||
|
p.resource !== 'gdpr'
|
||||||
|
)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
// Developer: alles
|
||||||
|
const developerPermIds = allPermissions.map((p) => p.id);
|
||||||
|
|
||||||
|
// DSGVO: audit + gdpr komplett
|
||||||
|
const gdprPermIds = allPermissions
|
||||||
|
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
||||||
|
const employeePermIds = allPermissions
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.resource === 'customers' ||
|
||||||
|
p.resource === 'contracts' ||
|
||||||
|
(p.action === 'read' &&
|
||||||
|
[
|
||||||
|
'platforms',
|
||||||
|
'providers',
|
||||||
|
'tariffs',
|
||||||
|
'cancellation-periods',
|
||||||
|
'contract-durations',
|
||||||
|
'contract-categories',
|
||||||
|
].includes(p.resource))
|
||||||
|
)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
||||||
|
const readOnlyResources = [
|
||||||
|
'customers',
|
||||||
|
'contracts',
|
||||||
|
'platforms',
|
||||||
|
'providers',
|
||||||
|
'tariffs',
|
||||||
|
'cancellation-periods',
|
||||||
|
'contract-durations',
|
||||||
|
'contract-categories',
|
||||||
|
];
|
||||||
|
const readOnlyPermIds = allPermissions
|
||||||
|
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||||
|
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||||
|
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||||
|
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||||
|
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||||
|
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
||||||
|
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of rolesSpec) {
|
||||||
|
const role = await prisma.role.upsert({
|
||||||
|
where: { name: r.name },
|
||||||
|
update: { description: r.description },
|
||||||
|
create: { name: r.name, description: r.description },
|
||||||
|
});
|
||||||
|
await syncRolePermissions(role.id, r.permIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[sync-roles] fertig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[sync-roles] Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,22 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist-Check (Pentest Runde 11, M1)
|
||||||
|
if (!appSettingService.isAllowedSettingKey(key)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Unbekannter Setting-Key: ${key}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
const oldValue = before?.value ?? '-';
|
||||||
const newValue = String(value);
|
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
||||||
|
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
||||||
|
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
||||||
|
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
|
|
||||||
await appSettingService.setSetting(key, newValue);
|
await appSettingService.setSetting(key, newValue);
|
||||||
|
|
||||||
@@ -78,12 +90,24 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
|
||||||
|
const unknownKeys = Object.keys(settings).filter(
|
||||||
|
(k) => !appSettingService.isAllowedSettingKey(k),
|
||||||
|
);
|
||||||
|
if (unknownKeys.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherige Werte laden für Audit
|
// Vorherige Werte laden für Audit
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
const oldValue = before?.value ?? '-';
|
||||||
const newValue = String(value);
|
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
if (oldValue !== newValue) {
|
if (oldValue !== newValue) {
|
||||||
changes[key] = { von: oldValue, nach: newValue };
|
changes[key] = { von: oldValue, nach: newValue };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,57 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response, CookieOptions } 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';
|
||||||
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
|
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
||||||
|
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
||||||
|
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
|
||||||
|
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
|
||||||
|
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
|
||||||
|
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||||
|
function getRefreshCookieOptions(): CookieOptions {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.HTTPS_ENABLED === 'true',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setRefreshCookie(res: Response, token: string): void {
|
||||||
|
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
|
||||||
|
}
|
||||||
|
function clearRefreshCookie(res: Response): void {
|
||||||
|
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
|
||||||
|
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
|
||||||
|
// wird als generisches "Anmeldung fehlgeschlagen" maskiert – die Original-
|
||||||
|
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
|
||||||
|
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
|
||||||
|
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
|
||||||
|
const SAFE_LOGIN_ERRORS = new Set([
|
||||||
|
'Ungültige Anmeldedaten',
|
||||||
|
'E-Mail und Passwort erforderlich',
|
||||||
|
]);
|
||||||
|
function safeLoginError(err: unknown): string {
|
||||||
|
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
|
||||||
|
}
|
||||||
|
return 'Anmeldung fehlgeschlagen';
|
||||||
|
}
|
||||||
|
|
||||||
// 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,20 +61,43 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.login(email, password);
|
const result = await authService.login(email, password);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
// Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
|
||||||
|
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
|
||||||
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
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: { token: result.accessToken, user: result.user },
|
||||||
|
} 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: safeLoginError(error),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,11 +107,32 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.customerLogin(email, password);
|
const result = await authService.customerLogin(email, password);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
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: { token: result.accessToken, user: result.user },
|
||||||
|
} 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: safeLoginError(error),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +203,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,
|
||||||
@@ -144,21 +244,43 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
// Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen
|
||||||
|
// verlangt und Portal-Customer-Reset weiterhin 12 reicht.
|
||||||
|
const audience = await authService.getPasswordResetAudience(token);
|
||||||
|
const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH;
|
||||||
|
const complexity = validatePasswordComplexity(password, { minLength });
|
||||||
|
if (!complexity.ok) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +288,87 @@ 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() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||||
|
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||||
|
// aber UI würde sich verirren).
|
||||||
|
clearRefreshCookie(res);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend
|
||||||
|
// (axios-Interceptor) bei 401 oder beim App-Start aufgerufen.
|
||||||
|
export async function refresh(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cookies = (req as any).cookies || {};
|
||||||
|
const refreshToken = cookies[REFRESH_COOKIE_NAME];
|
||||||
|
if (!refreshToken) {
|
||||||
|
res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.refreshAccessToken(refreshToken);
|
||||||
|
// Refresh-Cookie rotieren – verhindert Replay eines geklauten Refresh-Tokens
|
||||||
|
// bis zur vollen Lifetime.
|
||||||
|
setRefreshCookie(res, result.refreshToken);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { token: result.accessToken, user: result.user },
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||||
|
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||||
|
clearRefreshCookie(res);
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
||||||
|
} 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;
|
||||||
@@ -178,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mitarbeiter-Anlage: 25-Zeichen-Schwellwert
|
||||||
|
const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||||
|
if (!complexity.ok) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await authService.createUser({
|
const user = await authService.createUser({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -197,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
|
||||||
|
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
|
||||||
|
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
|
||||||
|
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
|
||||||
|
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: any = {
|
||||||
|
email: req.user.email,
|
||||||
|
permissions: req.user.permissions,
|
||||||
|
isCustomerPortal: !!req.user.isCustomerPortal,
|
||||||
|
};
|
||||||
|
if (req.user.userId) payload.userId = req.user.userId;
|
||||||
|
if (req.user.customerId) payload.customerId = req.user.customerId;
|
||||||
|
if ((req.user as any).representedCustomerIds) {
|
||||||
|
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
|
||||||
|
}
|
||||||
|
const token = authService.signDownloadToken(payload);
|
||||||
|
res.json({ success: true, data: { token } } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehler beim Erstellen des Download-Tokens',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
|
||||||
|
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
|
||||||
|
// loggt aus und schickt zurück zum Login.
|
||||||
|
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Nur für Kundenportal-Login',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
|
||||||
|
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
|
||||||
|
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) – KRITISCH.
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: req.user.customerId },
|
||||||
|
select: { portalPasswordMustChange: true },
|
||||||
|
});
|
||||||
|
if (!customer?.portalPasswordMustChange) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Nicht erlaubt',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { newPassword } = req.body || {};
|
||||||
|
if (!newPassword || typeof newPassword !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Neues Passwort erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const complexity = validatePasswordComplexity(newPassword);
|
||||||
|
if (!complexity.ok) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
|
||||||
|
clearRefreshCookie(res);
|
||||||
|
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as backupService from '../services/backup.service.js';
|
import * as backupService from '../services/backup.service.js';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
|
||||||
@@ -11,6 +12,83 @@ function isValidBackupName(name: string): boolean {
|
|||||||
}
|
}
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
|
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
|
||||||
|
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
|
||||||
|
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
|
||||||
|
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
|
||||||
|
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
|
||||||
|
// process-globale Patch-Variante.
|
||||||
|
function startLogCapture(): { lines: string[]; restore: () => void } {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const orig = {
|
||||||
|
log: console.log,
|
||||||
|
info: console.info,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
};
|
||||||
|
function fmt(args: unknown[]): string {
|
||||||
|
return args
|
||||||
|
.map((a) => {
|
||||||
|
if (a instanceof Error) return a.stack || a.message;
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(a);
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
|
||||||
|
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
|
||||||
|
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
|
||||||
|
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
restore: () => {
|
||||||
|
console.log = orig.log;
|
||||||
|
console.info = orig.info;
|
||||||
|
console.warn = orig.warn;
|
||||||
|
console.error = orig.error;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordBackupLog(opts: {
|
||||||
|
req: Request;
|
||||||
|
operation: 'CREATE' | 'RESTORE';
|
||||||
|
backupName: string | null;
|
||||||
|
success: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
summary: string;
|
||||||
|
fullLog: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const user = (opts.req as any).user;
|
||||||
|
await prisma.backupLog.create({
|
||||||
|
data: {
|
||||||
|
operation: opts.operation,
|
||||||
|
backupName: opts.backupName,
|
||||||
|
success: opts.success,
|
||||||
|
durationMs: opts.durationMs,
|
||||||
|
summary: opts.summary.slice(0, 2000),
|
||||||
|
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
|
||||||
|
fullLog: opts.fullLog.slice(0, 1_000_000),
|
||||||
|
userId: user?.userId ?? null,
|
||||||
|
userEmail: user?.email ?? null,
|
||||||
|
ipAddress:
|
||||||
|
(opts.req as any).socket?.remoteAddress ||
|
||||||
|
(opts.req.headers?.['x-forwarded-for'] as string) ||
|
||||||
|
null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste aller Backups abrufen
|
* Liste aller Backups abrufen
|
||||||
* GET /api/settings/backups
|
* GET /api/settings/backups
|
||||||
@@ -29,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
|
|||||||
* POST /api/settings/backup
|
* POST /api/settings/backup
|
||||||
*/
|
*/
|
||||||
export async function createBackup(req: Request, res: Response) {
|
export async function createBackup(req: Request, res: Response) {
|
||||||
|
const start = Date.now();
|
||||||
|
const capture = startLogCapture();
|
||||||
try {
|
try {
|
||||||
const result = await backupService.createBackup();
|
const result = await backupService.createBackup();
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
capture.restore();
|
||||||
|
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: result.backupName ?? null,
|
||||||
|
success: true, durationMs, summary,
|
||||||
|
fullLog: capture.lines.join('\n') || summary,
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Backup',
|
req, action: 'CREATE', resourceType: 'Backup',
|
||||||
label: `Backup ${result.backupName} erstellt`,
|
label: `Backup ${result.backupName} erstellt`,
|
||||||
});
|
});
|
||||||
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
||||||
} else {
|
} else {
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: null,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'CREATE', backupName: null,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Fehler: ${error?.message || 'unbekannt'}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
|
|||||||
* Backup wiederherstellen
|
* Backup wiederherstellen
|
||||||
* POST /api/settings/backup/:name/restore
|
* POST /api/settings/backup/:name/restore
|
||||||
*/
|
*/
|
||||||
|
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
|
||||||
|
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
|
||||||
|
// "Cannot read properties of undefined" → "Interner Code-Fehler".
|
||||||
|
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
|
||||||
|
function makeRestoreErrorReadable(raw: unknown): string {
|
||||||
|
if (!raw) return 'Unbekannter Fehler';
|
||||||
|
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
|
||||||
|
// Stack-Frames " at …(…:123:45)" abschneiden
|
||||||
|
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
|
||||||
|
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
|
||||||
|
// alles auf "Operation fehlgeschlagen" maskiert.
|
||||||
|
s = s
|
||||||
|
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
|
||||||
|
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
|
||||||
|
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
|
||||||
|
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
|
||||||
|
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
|
||||||
|
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
|
||||||
|
.replace(/is not defined$/i, '(Wert nicht definiert)')
|
||||||
|
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
|
||||||
|
return s.slice(0, 500); // Längenlimit für UI
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreBackup(req: Request, res: Response) {
|
export async function restoreBackup(req: Request, res: Response) {
|
||||||
try {
|
const start = Date.now();
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name || !isValidBackupName(name)) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
|
||||||
|
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
|
||||||
|
// sofort den destruktiven Restore aus – ein versehentlicher
|
||||||
|
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
|
||||||
|
// Klick) konnte die DB ungewollt überschreiben. Der String ist
|
||||||
|
// bewusst ein unique Magic-Value, kein Boolean.
|
||||||
|
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
|
||||||
|
if (confirm !== 'RESTORE-BESTAETIGT') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const capture = startLogCapture();
|
||||||
|
try {
|
||||||
const result = await backupService.restoreBackup(name);
|
const result = await backupService.restoreBackup(name);
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
capture.restore();
|
||||||
|
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: true, durationMs, summary,
|
||||||
|
fullLog: capture.lines.join('\n') || summary,
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'Backup',
|
req, action: 'UPDATE', resourceType: 'Backup',
|
||||||
label: `Backup ${name} wiederhergestellt`,
|
label: `Backup ${name} wiederhergestellt`,
|
||||||
@@ -73,10 +222,35 @@ export async function restoreBackup(req: Request, res: Response) {
|
|||||||
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
|
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
|
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Wiederherstellung fehlgeschlagen',
|
||||||
|
details: makeRestoreErrorReadable(result.error),
|
||||||
|
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
|
const durationMs = Date.now() - start;
|
||||||
|
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
|
||||||
|
capture.restore();
|
||||||
|
await recordBackupLog({
|
||||||
|
req, operation: 'RESTORE', backupName: name,
|
||||||
|
success: false, durationMs,
|
||||||
|
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
|
||||||
|
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Fehler bei der Wiederherstellung',
|
||||||
|
details: makeRestoreErrorReadable(error),
|
||||||
|
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +350,22 @@ export async function uploadBackup(req: Request, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function factoryReset(req: Request, res: Response) {
|
export async function factoryReset(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
// Bestätigung erforderlich: client MUSS explizit
|
||||||
|
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
|
||||||
|
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
|
||||||
|
// POST plätten (Pentest Runde 11 (2026-05-18) – C2 KRITISCH:
|
||||||
|
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
|
||||||
|
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
|
||||||
|
// Replay-Angriff aus Versehen triggern kann.
|
||||||
|
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
|
||||||
|
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await backupService.factoryReset();
|
const result = await backupService.factoryReset();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -190,6 +380,65 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
|
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
|
||||||
|
console.error('factoryReset error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste der Backup-Logs (CREATE oder RESTORE)
|
||||||
|
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
|
||||||
|
* Liefert die Übersichtsdaten OHNE den großen fullLog.
|
||||||
|
*/
|
||||||
|
export async function listBackupLogs(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const op = String(req.query.operation || '').toUpperCase();
|
||||||
|
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (op === 'CREATE' || op === 'RESTORE') {
|
||||||
|
where.operation = op;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await prisma.backupLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
operation: true,
|
||||||
|
backupName: true,
|
||||||
|
success: true,
|
||||||
|
durationMs: true,
|
||||||
|
summary: true,
|
||||||
|
userEmail: true,
|
||||||
|
ipAddress: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: logs });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail eines Backup-Logs inkl. fullLog
|
||||||
|
* GET /api/settings/backup-logs/:id
|
||||||
|
*/
|
||||||
|
export async function getBackupLogDetail(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige ID' });
|
||||||
|
}
|
||||||
|
const log = await prisma.backupLog.findUnique({ where: { id } });
|
||||||
|
if (!log) {
|
||||||
|
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.json({ data: log });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { createAuditLog } from '../services/audit.service.js';
|
|||||||
*/
|
*/
|
||||||
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
|
||||||
|
// Telefon und Geburtsdatum ALLER Kunden – ausschließlich Mitarbeiter-UI.
|
||||||
|
// Pentest Runde 6 (2026-05-16) – HOCH.
|
||||||
|
if (req.user?.isCustomerPortal) {
|
||||||
|
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
|
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
|
||||||
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,37 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
|||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||||
import { generateEmailPdf } from '../services/pdfService.js';
|
import { generateEmailPdf } from '../services/pdfService.js';
|
||||||
|
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||||
import { DocumentType } from '@prisma/client';
|
import { DocumentType } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { AuthRequest } from '../types/index.js';
|
|
||||||
import {
|
import {
|
||||||
canAccessCustomer,
|
canAccessCustomer,
|
||||||
canAccessContract,
|
canAccessContract,
|
||||||
canAccessCachedEmail,
|
canAccessCachedEmail,
|
||||||
|
canAccessStressfreiEmail,
|
||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
|
|
||||||
// ==================== E-MAIL LIST ====================
|
// ==================== E-MAIL LIST ====================
|
||||||
|
|
||||||
|
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
|
||||||
|
function parseBoolParam(v: unknown): boolean | undefined {
|
||||||
|
if (v === 'true') return true;
|
||||||
|
if (v === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateParam(v: unknown): Date | undefined {
|
||||||
|
if (typeof v !== 'string' || !v.trim()) return undefined;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +56,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
includeBody: false,
|
includeBody: false,
|
||||||
|
search: typeof req.query.search === 'string' ? req.query.search : undefined,
|
||||||
|
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
|
||||||
|
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
|
||||||
|
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
|
||||||
|
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
|
||||||
|
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
|
||||||
|
hasAttachments: parseBoolParam(req.query.hasAttachments),
|
||||||
|
isRead: parseBoolParam(req.query.isRead),
|
||||||
|
isStarred: parseBoolParam(req.query.isStarred),
|
||||||
|
receivedFrom: parseDateParam(req.query.receivedFrom),
|
||||||
|
receivedTo: parseDateParam(req.query.receivedTo),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
@@ -112,9 +138,10 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail als gelesen/ungelesen markieren
|
// E-Mail als gelesen/ungelesen markieren
|
||||||
export async function markAsRead(req: Request, res: Response): Promise<void> {
|
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
const { isRead } = req.body;
|
const { isRead } = req.body;
|
||||||
|
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
@@ -134,9 +161,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail Stern umschalten
|
// E-Mail Stern umschalten
|
||||||
export async function toggleStar(req: Request, res: Response): Promise<void> {
|
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||||||
|
|
||||||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||||||
@@ -152,10 +180,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
|
|||||||
// ==================== CONTRACT ASSIGNMENT ====================
|
// ==================== CONTRACT ASSIGNMENT ====================
|
||||||
|
|
||||||
// E-Mail einem Vertrag zuordnen
|
// E-Mail einem Vertrag zuordnen
|
||||||
export async function assignToContract(req: Request, res: Response): Promise<void> {
|
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const { contractId } = req.body;
|
const { contractId } = req.body;
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||||||
|
|
||||||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||||||
@@ -171,9 +201,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vertragszuordnung aufheben
|
// Vertragszuordnung aufheben
|
||||||
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
|
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||||||
|
|
||||||
@@ -188,9 +219,10 @@ export async function unassignFromContract(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für ein Konto
|
// E-Mail-Anzahl pro Ordner für ein Konto
|
||||||
export async function getFolderCounts(req: Request, res: Response): Promise<void> {
|
export async function getFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -205,9 +237,10 @@ export async function getFolderCounts(req: Request, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
||||||
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
|
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||||||
|
|
||||||
@@ -224,9 +257,10 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
|
|||||||
// ==================== SYNC & SEND ====================
|
// ==================== SYNC & SEND ====================
|
||||||
|
|
||||||
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
||||||
export async function syncAccount(req: Request, res: Response): Promise<void> {
|
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||||
const fullSync = req.query.full === 'true';
|
const fullSync = req.query.full === 'true';
|
||||||
|
|
||||||
// Synchronisiert sowohl INBOX als auch SENT
|
// Synchronisiert sowohl INBOX als auch SENT
|
||||||
@@ -256,12 +290,31 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
|
||||||
|
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
|
||||||
|
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
|
||||||
|
function hasCRLF(value: unknown): boolean {
|
||||||
|
if (typeof value === 'string') return /[\r\n]/.test(value);
|
||||||
|
if (Array.isArray(value)) return value.some(hasCRLF);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
||||||
|
|
||||||
|
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
|
||||||
|
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -514,10 +567,26 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei senden - inline (öffnen) oder attachment (download)
|
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||||
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
||||||
res.setHeader('Content-Type', attachment.contentType);
|
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
||||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
||||||
|
const INLINE_SAFE_TYPES = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||||
|
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
||||||
|
'text/plain',
|
||||||
|
]);
|
||||||
|
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
||||||
|
// SVG kann Skripte enthalten → niemals inline
|
||||||
|
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
||||||
|
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||||
|
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||||||
|
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||||||
|
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
||||||
res.setHeader('Content-Length', attachment.size);
|
res.setHeader('Content-Length', attachment.size);
|
||||||
res.send(attachment.content);
|
res.send(attachment.content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -547,9 +616,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
// ==================== MAILBOX ACCOUNTS ====================
|
// ==================== MAILBOX ACCOUNTS ====================
|
||||||
|
|
||||||
// Mailbox-Konten eines Kunden abrufen
|
// Mailbox-Konten eines Kunden abrufen
|
||||||
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
|
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||||||
|
|
||||||
@@ -564,9 +634,10 @@ export async function getMailboxAccounts(req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox nachträglich aktivieren
|
// Mailbox nachträglich aktivieren
|
||||||
export async function enableMailbox(req: Request, res: Response): Promise<void> {
|
export async function enableMailbox(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.enableMailbox(id);
|
const result = await stressfreiEmailService.enableMailbox(id);
|
||||||
|
|
||||||
@@ -589,9 +660,10 @@ export async function enableMailbox(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox-Status mit Provider synchronisieren
|
// Mailbox-Status mit Provider synchronisieren
|
||||||
export async function syncMailboxStatus(req: Request, res: Response): Promise<void> {
|
export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
||||||
|
|
||||||
@@ -620,9 +692,10 @@ export async function syncMailboxStatus(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Thread abrufen
|
// E-Mail-Thread abrufen
|
||||||
export async function getThread(req: Request, res: Response): Promise<void> {
|
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
|
|
||||||
const thread = await cachedEmailService.getEmailThread(id);
|
const thread = await cachedEmailService.getEmailThread(id);
|
||||||
|
|
||||||
@@ -637,9 +710,13 @@ export async function getThread(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||||||
export async function getMailboxCredentials(req: Request, res: Response): Promise<void> {
|
export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
// Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit
|
||||||
|
// bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials
|
||||||
|
// eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX.
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
||||||
@@ -674,6 +751,15 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
|
|||||||
// IMAP/SMTP-Einstellungen laden
|
// IMAP/SMTP-Einstellungen laden
|
||||||
const settings = await getImapSmtpSettings();
|
const settings = await getImapSmtpSettings();
|
||||||
|
|
||||||
|
// Klartext-Mailbox-Passwort-Read auditieren (CRITICAL)
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'MailboxCredentials',
|
||||||
|
resourceId: id.toString(),
|
||||||
|
label: `Klartext-Mailbox-Zugangsdaten von ${stressfreiEmail.email} entschlüsselt`,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -701,7 +787,7 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ungelesene E-Mails zählen
|
// Ungelesene E-Mails zählen
|
||||||
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
|
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||||||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||||||
@@ -709,8 +795,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||||||
} else if (contractId) {
|
} else if (contractId) {
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,9 +813,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail in Papierkorb verschieben (nur Admin)
|
// E-Mail in Papierkorb verschieben (nur Admin)
|
||||||
export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
|
|
||||||
// Prüfen ob E-Mail existiert
|
// Prüfen ob E-Mail existiert
|
||||||
const email = await cachedEmailService.getCachedEmailById(id);
|
const email = await cachedEmailService.getCachedEmailById(id);
|
||||||
@@ -762,9 +851,10 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
|||||||
// ==================== TRASH OPERATIONS ====================
|
// ==================== TRASH OPERATIONS ====================
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||||
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
|
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||||||
|
|
||||||
@@ -779,9 +869,10 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-Anzahl für einen Kunden
|
// Papierkorb-Anzahl für einen Kunden
|
||||||
export async function getTrashCount(req: Request, res: Response): Promise<void> {
|
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const count = await cachedEmailService.getTrashCount(customerId);
|
const count = await cachedEmailService.getTrashCount(customerId);
|
||||||
|
|
||||||
@@ -796,9 +887,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail aus Papierkorb wiederherstellen
|
// E-Mail aus Papierkorb wiederherstellen
|
||||||
export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
|
|
||||||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||||||
|
|
||||||
@@ -821,9 +913,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail endgültig löschen (aus Papierkorb)
|
// E-Mail endgültig löschen (aus Papierkorb)
|
||||||
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
|
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||||
|
|
||||||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||||||
|
|
||||||
@@ -848,9 +941,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
|
|||||||
// ==================== ATTACHMENT TARGETS ====================
|
// ==================== ATTACHMENT TARGETS ====================
|
||||||
|
|
||||||
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
||||||
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
|
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
// E-Mail mit StressfreiEmail laden
|
// E-Mail mit StressfreiEmail laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
@@ -1030,9 +1124,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anhang in ein Dokumentenfeld speichern
|
// E-Mail-Anhang in ein Dokumentenfeld speichern
|
||||||
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> {
|
export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { entityType, entityId, targetKey } = req.body;
|
const { entityType, entityId, targetKey } = req.body;
|
||||||
|
|
||||||
@@ -1317,9 +1412,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
|
|||||||
// ==================== SAVE EMAIL AS PDF ====================
|
// ==================== SAVE EMAIL AS PDF ====================
|
||||||
|
|
||||||
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
||||||
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
|
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const { entityType, entityId, targetKey } = req.body;
|
const { entityType, entityId, targetKey } = req.body;
|
||||||
|
|
||||||
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
||||||
@@ -1564,9 +1660,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
|
|||||||
// ==================== SAVE EMAIL AS INVOICE ====================
|
// ==================== SAVE EMAIL AS INVOICE ====================
|
||||||
|
|
||||||
// E-Mail als PDF exportieren und als Rechnung speichern
|
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||||
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
|
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||||||
@@ -1690,9 +1787,10 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
|||||||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||||
|
|
||||||
// E-Mail-Anhang als Rechnung speichern
|
// E-Mail-Anhang als Rechnung speichern
|
||||||
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
|
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
@@ -1853,9 +1951,10 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
|||||||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||||||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||||||
*/
|
*/
|
||||||
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
|
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { documentType, notes } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
@@ -1891,6 +1990,9 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
|
||||||
|
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
||||||
|
|
||||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||||
if (email.folder === 'SENT' && email.uid === 0) {
|
if (email.folder === 'SENT' && email.uid === 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -1967,6 +2069,10 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
|
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||||
|
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
|
|||||||
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: results });
|
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
|
||||||
|
// keine internen IDs – das war früher der volle CustomerConsent-Record und
|
||||||
|
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { granted: results.length },
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
||||||
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
|||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
|
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||||
|
|
||||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -45,9 +47,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
|||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
});
|
});
|
||||||
|
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
||||||
|
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
||||||
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const data = isPortal
|
||||||
|
? sanitizeContractsStrict(result.contracts as any[])
|
||||||
|
: sanitizeContracts(result.contracts as any[]);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.contracts,
|
data,
|
||||||
pagination: result.pagination,
|
pagination: result.pagination,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,7 +96,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: contract } as ApiResponse);
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const data = isPortal
|
||||||
|
? sanitizeContractStrict(contract as any)
|
||||||
|
: sanitizeContract(contract as any);
|
||||||
|
res.json({ success: true, data } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -97,8 +109,19 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContract(req: Request, res: Response): Promise<void> {
|
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
|
||||||
|
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
|
||||||
|
const body = (req.body || {}) as Record<string, unknown>;
|
||||||
|
if (!body.type || typeof body.type !== 'string') {
|
||||||
|
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.customerId || typeof body.customerId !== 'number') {
|
||||||
|
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const contract = await contractService.createContract(req.body);
|
const contract = await contractService.createContract(req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Contract',
|
req, action: 'CREATE', resourceType: 'Contract',
|
||||||
@@ -106,7 +129,9 @@ export async function createContract(req: Request, res: Response): Promise<void>
|
|||||||
label: `Vertrag ${contract.contractNumber} angelegt`,
|
label: `Vertrag ${contract.contractNumber} angelegt`,
|
||||||
customerId: contract.customerId,
|
customerId: contract.customerId,
|
||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -178,7 +203,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
customerId: before?.customerId,
|
customerId: before?.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: contract } as ApiResponse);
|
// Response sanitisieren – sonst leakt portalPasswordEncrypted etc.
|
||||||
|
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
|
||||||
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const sanitized = isPortal
|
||||||
|
? sanitizeContractStrict(contract as any)
|
||||||
|
: sanitizeContract(contract as any);
|
||||||
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -210,6 +241,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
|||||||
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const previousContractId = parseInt(req.params.id);
|
const previousContractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, previousContractId))) return;
|
||||||
|
|
||||||
// Vorgängervertrag laden für Vertragsnummer
|
// Vorgängervertrag laden für Vertragsnummer
|
||||||
const previousContract = await prisma.contract.findUnique({
|
const previousContract = await prisma.contract.findUnique({
|
||||||
@@ -246,7 +278,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
customerId: contract.customerId,
|
customerId: contract.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -255,6 +289,61 @@ 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);
|
||||||
|
if (!(await canAccessContract(req, res, previousContractId))) return;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
|
res.status(201).json({ success: true, data: sanitized } 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);
|
||||||
@@ -268,6 +357,14 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Klartext-Passwort-Read auditieren (CRITICAL)
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'ContractPassword',
|
||||||
|
resourceId: contractId.toString(),
|
||||||
|
label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
|
||||||
|
});
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -292,6 +389,14 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
|
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
|
||||||
|
|
||||||
const credentials = await contractService.getSimCardCredentials(simCardId);
|
const credentials = await contractService.getSimCardCredentials(simCardId);
|
||||||
|
// Klartext-Read (PIN/PUK) auditieren (CRITICAL)
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'SimCardCredentials',
|
||||||
|
resourceId: simCardId.toString(),
|
||||||
|
label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`,
|
||||||
|
});
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -307,6 +412,14 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
|
|||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
const credentials = await contractService.getInternetCredentials(contractId);
|
const credentials = await contractService.getInternetCredentials(contractId);
|
||||||
|
// Klartext-DSL/Internet-Login auditieren (CRITICAL)
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'InternetCredentials',
|
||||||
|
resourceId: contractId.toString(),
|
||||||
|
label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`,
|
||||||
|
});
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -331,6 +444,14 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
|
|||||||
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
|
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
|
||||||
|
|
||||||
const credentials = await contractService.getSipCredentials(phoneNumberId);
|
const credentials = await contractService.getSipCredentials(phoneNumberId);
|
||||||
|
// Klartext-SIP/Telefon-Login auditieren (CRITICAL)
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'SipCredentials',
|
||||||
|
resourceId: phoneNumberId.toString(),
|
||||||
|
label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`,
|
||||||
|
});
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -344,7 +465,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
|
|||||||
|
|
||||||
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cockpitData = await contractCockpitService.getCockpitData();
|
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
|
||||||
|
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
|
||||||
|
// (Pentest Runde 4, 2026-05-16: HOCH).
|
||||||
|
let customerIds: number[] | undefined;
|
||||||
|
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||||
|
customerIds = [req.user.customerId];
|
||||||
|
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||||
|
for (const repCustId of representedIds) {
|
||||||
|
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||||
|
if (hasAuth) {
|
||||||
|
customerIds.push(repCustId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
|
||||||
res.json({ success: true, data: cockpitData } as ApiResponse);
|
res.json({ success: true, data: cockpitData } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cockpit error:', error);
|
console.error('Cockpit error:', error);
|
||||||
@@ -426,6 +562,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
try {
|
try {
|
||||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'ContractMeter',
|
req, action: 'DELETE', resourceType: 'ContractMeter',
|
||||||
@@ -461,7 +598,8 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
|
|||||||
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
const { documentType, notes } = req.body;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
const { documentType, notes, deliveryDate } = req.body;
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||||
@@ -494,6 +632,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
customerId: contract?.customerId,
|
customerId: contract?.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
|
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -507,6 +648,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const documentId = parseInt(req.params.documentId);
|
const documentId = parseInt(req.params.documentId);
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
|
||||||
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
||||||
if (!doc || doc.contractId !== contractId) {
|
if (!doc || doc.contractId !== contractId) {
|
||||||
@@ -544,9 +686,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
||||||
|
|
||||||
export async function snoozeContract(req: Request, res: Response): Promise<void> {
|
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessContract(req, res, id))) return;
|
||||||
const { nextReviewDate, months } = req.body;
|
const { nextReviewDate, months } = req.body;
|
||||||
|
|
||||||
let reviewDate: Date | null = null;
|
let reviewDate: Date | null = null;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
|
|||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
|
|
||||||
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
||||||
res.json({ success: true, data: entries } as ApiResponse);
|
res.json({ success: true, data: entries } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -19,6 +21,7 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
|
|||||||
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { title, description } = req.body;
|
const { title, description } = req.body;
|
||||||
|
|
||||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
@@ -54,6 +57,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const entryId = parseInt(req.params.entryId);
|
const entryId = parseInt(req.params.entryId);
|
||||||
const { title, description } = req.body;
|
const { title, description } = req.body;
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const entryId = parseInt(req.params.entryId);
|
const entryId = parseInt(req.params.entryId);
|
||||||
|
|
||||||
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
||||||
|
|||||||
@@ -5,19 +5,30 @@ import * as customerService from '../services/customer.service.js';
|
|||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
|
||||||
|
|
||||||
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
||||||
|
|
||||||
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { status, customerId } = req.query;
|
const { status, customerId } = req.query;
|
||||||
|
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
|
||||||
|
|
||||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||||
|
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||||
let customerPortalCustomerIds: number[] | undefined;
|
let customerPortalCustomerIds: number[] | undefined;
|
||||||
let customerPortalEmails: string[] | undefined;
|
let customerPortalEmails: string[] | undefined;
|
||||||
|
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
if (allowedIds) {
|
||||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
// Wenn der Portal-User explizit nach einer customerId filtert, die er
|
||||||
|
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
|
||||||
|
// (Pentest Runde 10 – LOW: konsistentes Response-Verhalten nach
|
||||||
|
// Vollmacht-Widerruf).
|
||||||
|
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customerPortalCustomerIds = allowedIds;
|
||||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
@@ -26,7 +37,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
const tasks = await contractTaskService.getAllTasks({
|
const tasks = await contractTaskService.getAllTasks({
|
||||||
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
||||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
customerId: customerIdNum,
|
||||||
customerPortalCustomerIds,
|
customerPortalCustomerIds,
|
||||||
customerPortalEmails,
|
customerPortalEmails,
|
||||||
});
|
});
|
||||||
@@ -42,12 +53,13 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||||
|
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||||
let customerPortalCustomerIds: number[] | undefined;
|
let customerPortalCustomerIds: number[] | undefined;
|
||||||
let customerPortalEmails: string[] | undefined;
|
let customerPortalEmails: string[] | undefined;
|
||||||
|
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
if (allowedIds) {
|
||||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
customerPortalCustomerIds = allowedIds;
|
||||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
@@ -75,33 +87,17 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
|
|
||||||
// Prüfe Zugriff auf den Vertrag
|
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
|
||||||
const contract = await contractService.getContractById(contractId);
|
// hasAuthorization (Pentest Runde 6 – HOCH-04: widerrufene Vollmachten
|
||||||
if (!contract) {
|
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
|
||||||
res.status(404).json({
|
// konsultiert wurde, ohne Status-Check).
|
||||||
success: false,
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
error: 'Vertrag nicht gefunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Für Kundenportal: Zugriffsprüfung
|
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
|
||||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
|
||||||
if (!allowedCustomerIds.includes(contract.customerId)) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Kein Zugriff auf diesen Vertrag',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden
|
|
||||||
let customerPortalEmails: string[] | undefined;
|
let customerPortalEmails: string[] | undefined;
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
if (allowedIds) {
|
||||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
const customers = await customerService.getCustomersByIds(allowedIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
.filter((email: string | null): email is string => !!email);
|
.filter((email: string | null): email is string => !!email);
|
||||||
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe Zugriff auf den Vertrag
|
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
|
||||||
const contract = await contractService.getContractById(contractId);
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
if (!contract) {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Vertrag nicht gefunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zugriffsprüfung für Kundenportal
|
|
||||||
if (req.user?.customerId) {
|
|
||||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
|
||||||
if (!allowedCustomerIds.includes(contract.customerId)) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Kein Zugriff auf diesen Vertrag',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdBy = req.user?.email;
|
const createdBy = req.user?.email;
|
||||||
|
|
||||||
@@ -376,15 +353,26 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
|
if (!req.user?.isCustomerPortal || !req.user.customerId) {
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Nur für Kundenportal-Benutzer',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||||
|
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||||
|
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||||
|
if (!(await canAccessContract(req, res, task.contractId))) return;
|
||||||
|
|
||||||
|
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
|
||||||
|
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
|
||||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||||
const allowedEmails = customers
|
const allowedEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
.filter((email: string | null): email is string => !!email);
|
.filter((email: string | null): email is string => !!email);
|
||||||
|
|
||||||
// Task muss entweder visibleInPortal sein ODER vom Kunden erstellt worden sein
|
|
||||||
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
|
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
|
||||||
if (!task.visibleInPortal && !isOwnTask) {
|
if (!task.visibleInPortal && !isOwnTask) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
@@ -393,13 +381,6 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Nur für Kundenportal-Benutzer',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdBy = req.user?.email;
|
const createdBy = req.user?.email;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
sanitizeCustomer,
|
sanitizeCustomer,
|
||||||
@@ -10,29 +11,42 @@ import {
|
|||||||
sanitizeCustomerStrict,
|
sanitizeCustomerStrict,
|
||||||
pickCustomerCreate,
|
pickCustomerCreate,
|
||||||
pickCustomerUpdate,
|
pickCustomerUpdate,
|
||||||
|
isValidEmail,
|
||||||
} from '../utils/sanitize.js';
|
} from '../utils/sanitize.js';
|
||||||
import {
|
import {
|
||||||
canAccessMeter,
|
canAccessMeter,
|
||||||
canAccessAddress,
|
canAccessAddress,
|
||||||
canAccessBankCard,
|
canAccessBankCard,
|
||||||
canAccessIdentityDocument,
|
canAccessIdentityDocument,
|
||||||
|
canAccessCustomer,
|
||||||
|
getPortalAllowedCustomerIds,
|
||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
|
|
||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { search, type, page, limit } = req.query;
|
const { search, type, page, limit } = req.query;
|
||||||
|
|
||||||
|
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
|
||||||
|
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
|
||||||
|
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
|
||||||
|
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
|
||||||
|
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||||
|
|
||||||
const result = await customerService.getAllCustomers({
|
const result = await customerService.getAllCustomers({
|
||||||
search: search as string,
|
search: search as string,
|
||||||
type: type as 'PRIVATE' | 'BUSINESS',
|
type: type as 'PRIVATE' | 'BUSINESS',
|
||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
|
allowedIds: allowedIds ?? undefined,
|
||||||
});
|
});
|
||||||
|
const customers = result.customers as any[];
|
||||||
|
|
||||||
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
|
||||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||||
const sanitized = canSeePasswords
|
const sanitized = canSeePasswords
|
||||||
? sanitizeCustomers(result.customers as any)
|
? sanitizeCustomers(customers)
|
||||||
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
||||||
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -44,7 +58,9 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
|
|||||||
|
|
||||||
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
const customerId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const customer = await customerService.getCustomerById(customerId);
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -64,6 +80,16 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||||
const data: any = pickCustomerCreate(req.body);
|
const data: any = pickCustomerCreate(req.body);
|
||||||
|
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||||
|
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||||
|
if (data.email && !isValidEmail(data.email)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Convert birthDate string to Date if present
|
// Convert birthDate string to Date if present
|
||||||
if (data.birthDate) {
|
if (data.birthDate) {
|
||||||
data.birthDate = new Date(data.birthDate);
|
data.birthDate = new Date(data.birthDate);
|
||||||
@@ -75,7 +101,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
|
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: customer } as ApiResponse);
|
// Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
|
||||||
|
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
|
||||||
|
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
|
||||||
|
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
|
||||||
|
const sanitized = canSeePasswords
|
||||||
|
? sanitizeCustomer(customer as any)
|
||||||
|
: sanitizeCustomerStrict(customer as any);
|
||||||
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -88,6 +121,15 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
|
||||||
|
if (req.body?.email && !isValidEmail(req.body.email)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
const data: any = pickCustomerUpdate(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -154,7 +196,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: customer } as ApiResponse);
|
// Response sanitisieren – sonst leakt portalPasswordHash +
|
||||||
|
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
|
||||||
|
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
|
||||||
|
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
|
||||||
|
const sanitized = canSeePasswords
|
||||||
|
? sanitizeCustomer(customer as any)
|
||||||
|
: sanitizeCustomerStrict(customer as any);
|
||||||
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update customer error:', error);
|
console.error('Update customer error:', error);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -185,18 +234,21 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Addresses
|
// Addresses
|
||||||
export async function getAddresses(req: Request, res: Response): Promise<void> {
|
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const addresses = await customerService.getCustomerAddresses(customerId);
|
||||||
res.json({ success: true, data: addresses } as ApiResponse);
|
res.json({ success: true, data: addresses } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAddress(req: Request, res: Response): Promise<void> {
|
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const address = await customerService.createAddress(customerId, req.body);
|
const address = await customerService.createAddress(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Address',
|
req, action: 'CREATE', resourceType: 'Address',
|
||||||
@@ -213,9 +265,10 @@ export async function createAddress(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAddress(req: Request, res: Response): Promise<void> {
|
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const addressId = parseInt(req.params.id);
|
const addressId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessAddress(req, res, addressId))) return;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -276,9 +329,10 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAddress(req: Request, res: Response): Promise<void> {
|
export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const addressId = parseInt(req.params.id);
|
const addressId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessAddress(req, res, addressId))) return;
|
||||||
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
|
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
|
||||||
const customerId = addr?.customerId;
|
const customerId = addr?.customerId;
|
||||||
await customerService.deleteAddress(addressId);
|
await customerService.deleteAddress(addressId);
|
||||||
@@ -298,22 +352,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bank Cards
|
// Bank Cards
|
||||||
export async function getBankCards(req: Request, res: Response): Promise<void> {
|
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const cards = await customerService.getCustomerBankCards(
|
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
|
||||||
parseInt(req.params.customerId),
|
|
||||||
showInactive
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: cards } as ApiResponse);
|
res.json({ success: true, data: cards } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBankCard(req: Request, res: Response): Promise<void> {
|
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const card = await customerService.createBankCard(customerId, req.body);
|
const card = await customerService.createBankCard(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
@@ -330,9 +384,10 @@ export async function createBankCard(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBankCard(req: Request, res: Response): Promise<void> {
|
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -388,9 +443,10 @@ export async function updateBankCard(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBankCard(req: Request, res: Response): Promise<void> {
|
export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||||
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
|
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
|
||||||
const customerId = card?.customerId;
|
const customerId = card?.customerId;
|
||||||
await customerService.deleteBankCard(cardId);
|
await customerService.deleteBankCard(cardId);
|
||||||
@@ -410,22 +466,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Identity Documents
|
// Identity Documents
|
||||||
export async function getDocuments(req: Request, res: Response): Promise<void> {
|
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const docs = await customerService.getCustomerDocuments(
|
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
|
||||||
parseInt(req.params.customerId),
|
|
||||||
showInactive
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: docs } as ApiResponse);
|
res.json({ success: true, data: docs } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDocument(req: Request, res: Response): Promise<void> {
|
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const doc = await customerService.createDocument(customerId, req.body);
|
const doc = await customerService.createDocument(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
||||||
@@ -442,9 +498,10 @@ export async function createDocument(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDocument(req: Request, res: Response): Promise<void> {
|
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const docId = parseInt(req.params.id);
|
const docId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -506,9 +563,10 @@ export async function updateDocument(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocument(req: Request, res: Response): Promise<void> {
|
export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const docId = parseInt(req.params.id);
|
const docId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
||||||
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
|
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
|
||||||
const customerId = doc?.customerId;
|
const customerId = doc?.customerId;
|
||||||
await customerService.deleteDocument(docId);
|
await customerService.deleteDocument(docId);
|
||||||
@@ -528,22 +586,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meters
|
// Meters
|
||||||
export async function getMeters(req: Request, res: Response): Promise<void> {
|
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const meters = await customerService.getCustomerMeters(
|
const meters = await customerService.getCustomerMeters(customerId, showInactive);
|
||||||
parseInt(req.params.customerId),
|
|
||||||
showInactive
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: meters } as ApiResponse);
|
res.json({ success: true, data: meters } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMeter(req: Request, res: Response): Promise<void> {
|
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const meter = await customerService.createMeter(customerId, req.body);
|
const meter = await customerService.createMeter(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Meter',
|
req, action: 'CREATE', resourceType: 'Meter',
|
||||||
@@ -560,9 +618,10 @@ export async function createMeter(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMeter(req: Request, res: Response): Promise<void> {
|
export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.id);
|
const meterId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -617,9 +676,10 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMeter(req: Request, res: Response): Promise<void> {
|
export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.id);
|
const meterId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
await customerService.deleteMeter(meterId);
|
await customerService.deleteMeter(meterId);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'Meter',
|
req, action: 'DELETE', resourceType: 'Meter',
|
||||||
@@ -647,10 +707,11 @@ export async function getMeterReadings(req: AuthRequest, res: Response): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMeterReading(req: Request, res: Response): Promise<void> {
|
export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||||
const meterId = parseInt(req.params.meterId);
|
const meterId = parseInt(req.params.meterId);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
const reading = await customerService.addMeterReading(meterId, {
|
const reading = await customerService.addMeterReading(meterId, {
|
||||||
readingDate: new Date(readingDate),
|
readingDate: new Date(readingDate),
|
||||||
value: parseFloat(value),
|
value: parseFloat(value),
|
||||||
@@ -683,8 +744,10 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
|
export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const meterId = parseInt(req.params.meterId);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
||||||
@@ -694,7 +757,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
|
|||||||
if (notes !== undefined) updateData.notes = notes;
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
|
||||||
const reading = await customerService.updateMeterReading(
|
const reading = await customerService.updateMeterReading(
|
||||||
parseInt(req.params.meterId),
|
meterId,
|
||||||
parseInt(req.params.readingId),
|
parseInt(req.params.readingId),
|
||||||
updateData as any
|
updateData as any
|
||||||
);
|
);
|
||||||
@@ -712,13 +775,12 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMeterReading(req: Request, res: Response): Promise<void> {
|
export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const meterId = parseInt(req.params.meterId);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
const readingId = parseInt(req.params.readingId);
|
const readingId = parseInt(req.params.readingId);
|
||||||
await customerService.deleteMeterReading(
|
await customerService.deleteMeterReading(meterId, readingId);
|
||||||
parseInt(req.params.meterId),
|
|
||||||
readingId
|
|
||||||
);
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'MeterReading',
|
req, action: 'DELETE', resourceType: 'MeterReading',
|
||||||
resourceId: readingId.toString(),
|
resourceId: readingId.toString(),
|
||||||
@@ -819,6 +881,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
|
|||||||
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
const meterId = parseInt(req.params.meterId);
|
||||||
|
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||||
const readingId = parseInt(req.params.readingId);
|
const readingId = parseInt(req.params.readingId);
|
||||||
|
|
||||||
const reading = await prisma.meterReading.update({
|
const reading = await prisma.meterReading.update({
|
||||||
@@ -847,9 +910,11 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
// ==================== PORTAL SETTINGS ====================
|
// ==================== PORTAL SETTINGS ====================
|
||||||
|
|
||||||
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
|
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const settings = await customerService.getPortalSettings(customerId);
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -876,7 +941,27 @@ export async function getPortalSettings(req: Request, res: Response): Promise<vo
|
|||||||
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
const { portalEnabled, portalEmail } = req.body;
|
// `password` (oder password-ähnliche Felder) gehören NICHT in den
|
||||||
|
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
|
||||||
|
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
|
||||||
|
// ein Passwort setzen will, nutzt POST /portal/password mit
|
||||||
|
// Komplexitäts-Check. (Pentest-Befund.)
|
||||||
|
const body = req.body || {};
|
||||||
|
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
|
||||||
|
const offending = forbidden.filter((k) => k in body);
|
||||||
|
if (offending.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { portalEnabled, portalEmail } = body;
|
||||||
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
|
||||||
|
if (portalEmail && !isValidEmail(portalEmail)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({
|
const before = await prisma.customer.findUnique({
|
||||||
@@ -936,13 +1021,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
|
||||||
|
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
|
||||||
|
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
|
||||||
|
*/
|
||||||
|
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const password = generateSecurePassword({ length: 16 });
|
||||||
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
|
||||||
|
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
|
||||||
|
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
|
||||||
|
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
|
||||||
|
*/
|
||||||
|
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: {
|
||||||
|
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
|
||||||
|
email: true, portalEmail: true, portalEnabled: true,
|
||||||
|
portalPasswordEncrypted: true, portalPasswordHash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!customer) {
|
||||||
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customer.portalEnabled) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Portal ist für diesen Kunden nicht aktiviert',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customer.portalPasswordHash) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Es ist noch kein Portal-Passwort gesetzt',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetEmail = customer.email || customer.portalEmail;
|
||||||
|
if (!targetEmail) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginEmail = customer.portalEmail || customer.email!;
|
||||||
|
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
|
||||||
|
if (!plaintextPassword) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.sendPortalCredentialsEmail({
|
||||||
|
to: targetEmail,
|
||||||
|
customer,
|
||||||
|
loginEmail,
|
||||||
|
password: plaintextPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
|
||||||
|
// der Kunde sich ein eigenes setzen.
|
||||||
|
await authService.markPortalPasswordForChange(customerId);
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'PortalSettings',
|
||||||
|
resourceId: customerId.toString(),
|
||||||
|
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
|
||||||
|
customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
|
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
if (!password || password.length < 6) {
|
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
|
||||||
|
const complexity = validatePasswordComplexity(password);
|
||||||
|
if (!complexity.ok) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Passwort muss mindestens 6 Zeichen lang sein',
|
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -963,9 +1150,22 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
|
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const password = await authService.getCustomerPortalPassword(parseInt(req.params.customerId));
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const password = await authService.getCustomerPortalPassword(customerId);
|
||||||
|
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
||||||
|
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
||||||
|
// + Insider-Threat-Erkennung.
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'READ',
|
||||||
|
resourceType: 'PortalPassword',
|
||||||
|
resourceId: customerId.toString(),
|
||||||
|
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
|
||||||
|
customerId,
|
||||||
|
});
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -977,10 +1177,12 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
|
|
||||||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||||||
|
|
||||||
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
|
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
// Wer kann diesen Kunden vertreten (representedBy)?
|
// Wer kann diesen Kunden vertreten (representedBy)?
|
||||||
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
|
const representedBy = await customerService.getRepresentedByList(customerId);
|
||||||
res.json({ success: true, data: representedBy } as ApiResponse);
|
res.json({ success: true, data: representedBy } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -990,9 +1192,10 @@ export async function getRepresentatives(req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addRepresentative(req: Request, res: Response): Promise<void> {
|
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const { representativeId, notes } = req.body;
|
const { representativeId, notes } = req.body;
|
||||||
const representative = await customerService.addRepresentative(
|
const representative = await customerService.addRepresentative(
|
||||||
customerId,
|
customerId,
|
||||||
@@ -1014,9 +1217,10 @@ export async function addRepresentative(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeRepresentative(req: Request, res: Response): Promise<void> {
|
export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
await customerService.removeRepresentative(
|
await customerService.removeRepresentative(
|
||||||
customerId,
|
customerId,
|
||||||
parseInt(req.params.representativeId)
|
parseInt(req.params.representativeId)
|
||||||
@@ -1035,8 +1239,13 @@ export async function removeRepresentative(req: Request, res: Response): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchForRepresentative(req: Request, res: Response): Promise<void> {
|
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
|
||||||
|
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
|
||||||
|
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
|
||||||
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const { search } = req.query;
|
const { search } = req.query;
|
||||||
if (!search || typeof search !== 'string' || search.length < 2) {
|
if (!search || typeof search !== 'string' || search.length < 2) {
|
||||||
res.json({ success: true, data: [] } as ApiResponse);
|
res.json({ success: true, data: [] } as ApiResponse);
|
||||||
@@ -1044,7 +1253,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
|
|||||||
}
|
}
|
||||||
const customers = await customerService.searchCustomersForRepresentative(
|
const customers = await customerService.searchCustomersForRepresentative(
|
||||||
search,
|
search,
|
||||||
parseInt(req.params.customerId)
|
customerId,
|
||||||
);
|
);
|
||||||
res.json({ success: true, data: customers } as ApiResponse);
|
res.json({ success: true, data: customers } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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,98 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
|
||||||
|
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
|
||||||
|
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
|
||||||
|
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
||||||
|
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
||||||
|
// Extension – also `text/html` – und der Browser hätte das als
|
||||||
|
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
|
||||||
|
// nicht, wenn der Server selbst text/html liefert.
|
||||||
|
//
|
||||||
|
// Fix: alle Files via Content-Disposition: attachment ausliefern.
|
||||||
|
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
|
||||||
|
// Für legitime PDF/Bild-Vorschau ist das vertretbar – Browser
|
||||||
|
// öffnen den Download dann eben aus dem Datei-Manager.
|
||||||
|
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.sendFile(absolute);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
|
|||||||
import * as consentService from '../services/consent.service.js';
|
import * as consentService from '../services/consent.service.js';
|
||||||
import * as consentPublicService from '../services/consent-public.service.js';
|
import * as consentPublicService from '../services/consent-public.service.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
|
import { canAccessCustomer } from '../utils/accessControl.js';
|
||||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
@@ -12,6 +13,7 @@ import fs from 'fs';
|
|||||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
|
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
|
||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kundendaten exportieren (DSGVO Art. 15)
|
* Kundendaten exportieren (DSGVO Art. 15)
|
||||||
@@ -229,6 +231,7 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
|
|||||||
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const consents = await consentService.getCustomerConsents(customerId);
|
const consents = await consentService.getCustomerConsents(customerId);
|
||||||
|
|
||||||
// Labels hinzufügen
|
// Labels hinzufügen
|
||||||
@@ -251,6 +254,7 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
|||||||
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const result = await consentService.hasFullConsent(customerId);
|
const result = await consentService.hasFullConsent(customerId);
|
||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -266,7 +270,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
const consentType = req.params.consentType as ConsentType;
|
const consentType = req.params.consentType as ConsentType;
|
||||||
const { status, source, documentPath, version } = req.body;
|
// BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
|
||||||
|
// und `version` darf der Portal-User NICHT setzen – Pentest 2026-05-20
|
||||||
|
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
|
||||||
|
// landeten vorher ungefiltert in der DB. source ist für diesen
|
||||||
|
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
|
||||||
|
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
|
||||||
|
// (falls überhaupt) später nach.
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
|
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
|
||||||
if (!(req.user as any)?.isCustomerPortal) {
|
if (!(req.user as any)?.isCustomerPortal) {
|
||||||
@@ -276,17 +287,9 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portal: nur eigene + vertretene Kunden
|
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
|
||||||
const allowed = [
|
// widerrufene Vollmachten hatten vorher noch Zugriff)
|
||||||
(req.user as any).customerId,
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
...((req.user as any).representedCustomerIds || []),
|
|
||||||
];
|
|
||||||
if (!allowed.includes(customerId)) {
|
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Keine Berechtigung für diesen Kunden',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.values(ConsentType).includes(consentType)) {
|
if (!Object.values(ConsentType).includes(consentType)) {
|
||||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||||
@@ -301,9 +304,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
const consent = await consentService.updateConsent(customerId, consentType, {
|
const consent = await consentService.updateConsent(customerId, consentType, {
|
||||||
status,
|
status,
|
||||||
source: source || 'portal',
|
source: 'portal',
|
||||||
documentPath,
|
|
||||||
version,
|
|
||||||
ipAddress: req.socket.remoteAddress,
|
ipAddress: req.socket.remoteAddress,
|
||||||
createdBy: req.user?.email || 'unknown',
|
createdBy: req.user?.email || 'unknown',
|
||||||
});
|
});
|
||||||
@@ -312,7 +313,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'CustomerConsent',
|
req, action: 'UPDATE', resourceType: 'CustomerConsent',
|
||||||
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
|
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
|
||||||
details: { einwilligung: consentName, status, quelle: source || 'portal' },
|
details: { einwilligung: consentName, status, quelle: 'portal' },
|
||||||
customerId,
|
customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,6 +800,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
||||||
await authorizationService.ensureAuthorizationEntries(customerId);
|
await authorizationService.ensureAuthorizationEntries(customerId);
|
||||||
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
||||||
@@ -818,9 +820,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
|||||||
const representativeId = parseInt(req.params.representativeId);
|
const representativeId = parseInt(req.params.representativeId);
|
||||||
const { source, notes } = req.body;
|
const { source, notes } = req.body;
|
||||||
|
|
||||||
|
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
|
||||||
|
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
|
||||||
|
// stripHtml geschickt (Plain-Text-Feld).
|
||||||
|
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
|
||||||
|
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
|
||||||
|
|
||||||
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
|
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
|
||||||
source: source || 'crm-backend',
|
source: safeSource,
|
||||||
notes,
|
notes: safeNotes as string | undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
||||||
@@ -883,6 +891,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
|
||||||
|
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
|
||||||
|
// hochladen. Wir verlangen:
|
||||||
|
// 1) Magic-Bytes "%PDF-" am Anfang
|
||||||
|
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
|
||||||
|
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
|
||||||
|
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
|
||||||
|
// passierte die reine Magic-Byte-Prüfung).
|
||||||
|
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
|
||||||
|
// hier nicht erkannt – aber das ist Adobe-Acrobat-Risiko und nicht
|
||||||
|
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
|
||||||
|
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(req.file.path);
|
||||||
|
const fd = fs.openSync(req.file.path, 'r');
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const head = Buffer.alloc(5);
|
||||||
|
fs.readSync(fd, head, 0, 5, 0);
|
||||||
|
if (!head.equals(PDF_MAGIC)) {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
|
||||||
|
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
|
||||||
|
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
|
||||||
|
// klare Spoof-Indikatoren.
|
||||||
|
const headSize = Math.min(stat.size, 4096);
|
||||||
|
const headBuf = Buffer.alloc(headSize);
|
||||||
|
fs.readSync(fd, headBuf, 0, headSize, 0);
|
||||||
|
const headStr = headBuf.toString('latin1').toLowerCase();
|
||||||
|
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz | ||||||