Compare commits
49 Commits
main
..
5c77a57944
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c77a57944 | |||
| f5a74864a2 | |||
| 6175421a4c | |||
| d1005f9730 | |||
| 2b2e0aa497 | |||
| d2766f3621 | |||
| ea3f3c6d29 | |||
| d1e78b4b8e | |||
| 9e55e25dc8 | |||
| a47dfcd841 | |||
| f17adb6095 | |||
| 50b0e56a84 | |||
| 29eceef26b | |||
| 5e9e553882 | |||
| 5b85bea4eb | |||
| 3dd4f7b656 | |||
| eaa94e766a | |||
| 219e1930f7 | |||
| 4f359df161 | |||
| 0121c82412 | |||
| a9643206bb | |||
| f2876f877e | |||
| 89cf92eaf5 | |||
| dd4d57fa1b | |||
| e348e86c60 | |||
| ee4f1aacdd | |||
| 06489299d5 | |||
| 4442ab08b3 | |||
| efe8ac25cb | |||
| 3a9fcc5ec9 | |||
| d400c90e6a | |||
| aee48a8ccb | |||
| 1ad4fe0819 | |||
| b281801cdb | |||
| af2f444a24 | |||
| 2d052c76d9 | |||
| d98c97a81f | |||
| 06d45734ce | |||
| b968e6b46d | |||
| 312e879221 | |||
| fdef6d1d3b | |||
| 2b23ed64c4 | |||
| 97b4670643 | |||
| 9a014c100b | |||
| 6f3ab288ed | |||
| ee8bd7a8f7 | |||
| e4fdfbc95f | |||
| ff857be01a | |||
| 31f807fbd0 |
@@ -46,15 +46,6 @@ backups
|
||||
backend/uploads
|
||||
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)
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# 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
@@ -1,41 +0,0 @@
|
||||
# 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,8 +2,6 @@
|
||||
|
||||
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
||||
|
||||
**Version: 1.1.0** ([Changelog](#changelog))
|
||||
|
||||
## Features
|
||||
|
||||
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
||||
@@ -13,9 +11,6 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
||||
- **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**:
|
||||
- Energie (Strom, Gas)
|
||||
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
||||
@@ -25,14 +20,7 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
||||
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
||||
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
||||
- **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)
|
||||
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
||||
|
||||
## Tech Stack
|
||||
@@ -41,70 +29,38 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
||||
- **Backend**: Node.js, Express 4.x, TypeScript
|
||||
- **Datenbank**: MariaDB
|
||||
- **ORM**: Prisma
|
||||
- **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.
|
||||
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle
|
||||
|
||||
> **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 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
|
||||
|
||||
- Docker & Docker Compose v2
|
||||
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
|
||||
- Node.js 18+ (empfohlen: 20+)
|
||||
- Docker & Docker Compose
|
||||
- npm
|
||||
|
||||
## Installation für Entwicklung (ohne Container)
|
||||
## Installation
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd opencrm
|
||||
cp .env.example .env # Konfiguration anpassen
|
||||
```
|
||||
|
||||
### 2. MariaDB-Container starten
|
||||
### 2. MariaDB-Datenbank starten
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Das startet nur die Datenbank (mit Daten in `./data/db/`).
|
||||
Konfiguration kommt aus `./.env`:
|
||||
|
||||
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
|
||||
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
|
||||
Dies startet einen MariaDB-Container mit:
|
||||
- **Port:** 3306
|
||||
- **Datenbank:** opencrm
|
||||
- **Root-Passwort:** rootpassword
|
||||
- **Benutzer:** opencrm / opencrm123
|
||||
|
||||
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
|
||||
|
||||
@@ -126,14 +82,9 @@ Die `.env`-Datei sollte folgende Werte enthalten:
|
||||
# Database
|
||||
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
||||
|
||||
# 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
|
||||
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
||||
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
|
||||
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
@@ -189,197 +140,6 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
||||
- **E-Mail:** admin@admin.com
|
||||
- **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
|
||||
|
||||
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
||||
@@ -410,84 +170,12 @@ Das System unterstützt die automatische Erstellung von E-Mail-Weiterleitungen a
|
||||
- **Name**: Bezeichnung (z.B. "Plesk Hauptserver")
|
||||
- **Typ**: Plesk/cPanel/DirectAdmin
|
||||
- **API-URL**: Server-URL (z.B. `https://server.de:8443`)
|
||||
- **API-Key** _(empfohlen bei Plesk)_: Key aus Plesk (siehe unten), alternativ Benutzername/Passwort
|
||||
- **Benutzername/Passwort**: Nur wenn kein API-Key vorhanden
|
||||
- **Benutzername/Passwort**: API-Zugangsdaten
|
||||
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
|
||||
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
|
||||
3. Provider als "Standard" und "Aktiv" markieren
|
||||
4. Verbindung testen
|
||||
|
||||
### Plesk: API-Key anlegen
|
||||
|
||||
Der API-Key ist die empfohlene Authentifizierungsmethode (sicherer als Passwort, kann pro
|
||||
Anwendung vergeben und widerrufen werden).
|
||||
|
||||
**Variante 1: Über die Plesk-Oberfläche (einfachster Weg)**
|
||||
|
||||
1. In Plesk als Admin einloggen
|
||||
2. Oben rechts auf den **eigenen Namen** → **"Mein Profil"** (oder direkt URL `/admin/my-profile/`)
|
||||
3. Tab **"API-Token"** oder **"API-Schlüssel"** öffnen
|
||||
4. **"API-Schlüssel erstellen"** (bzw. "Add API Key")
|
||||
5. Beschreibung vergeben (z.B. "OpenCRM")
|
||||
6. Den angezeigten Schlüssel **sofort kopieren** – er wird nur einmal angezeigt!
|
||||
7. Im CRM bei "API-Key" einfügen
|
||||
|
||||
> **Hinweis:** Bei manchen Plesk-Versionen ist die Option unter
|
||||
> **Tools & Einstellungen** → **API-Schlüssel** oder **Werkzeuge & Einstellungen** →
|
||||
> **API-Tokens** zu finden. Wenn der Menüpunkt fehlt, muss ggf. die **REST API**
|
||||
> Extension installiert werden (siehe Variante 2).
|
||||
|
||||
**Variante 2: Über die Kommandozeile (SSH als root)**
|
||||
|
||||
Falls der API-Key-Button in Plesk nicht vorhanden ist, lässt er sich auch per SSH erstellen:
|
||||
|
||||
```bash
|
||||
# API-Key generieren (läuft nicht ab)
|
||||
# WICHTIG: -ip-address weglassen, wenn der Key von beliebigen IPs genutzt werden soll!
|
||||
plesk bin secret_key --create -description "OpenCRM"
|
||||
|
||||
# Alternativ mit IP-Einschränkung (nur Zugriffe von dieser IP sind erlaubt):
|
||||
plesk bin secret_key --create -ip-address <IP-DES-CRM-SERVERS> -description "OpenCRM"
|
||||
```
|
||||
|
||||
> **Achtung:** `-ip-address 0.0.0.0` funktioniert **nicht** wie bei anderen Tools!
|
||||
> Plesk prüft exakt gegen die eingetragene IP. Für "alle IPs erlauben" muss der
|
||||
> `-ip-address`-Parameter komplett weggelassen werden.
|
||||
|
||||
Der Befehl gibt den Key direkt zurück. Diesen kopieren und im CRM eintragen.
|
||||
|
||||
**Alle API-Keys anzeigen:**
|
||||
```bash
|
||||
plesk bin secret_key --list
|
||||
```
|
||||
|
||||
**API-Key löschen:**
|
||||
```bash
|
||||
plesk bin secret_key --delete <KEY>
|
||||
```
|
||||
|
||||
### Plesk: REST API aktivieren (falls nicht vorhanden)
|
||||
|
||||
Bei älteren Plesk-Versionen oder Custom-Installationen kann es sein, dass die
|
||||
REST API fehlt. Dann:
|
||||
|
||||
1. **Tools & Einstellungen** → **Updates** → **Erweiterungen hinzufügen/entfernen**
|
||||
2. Nach **"REST API"** suchen und installieren
|
||||
3. Plesk-Neustart (meist nicht nötig, aber zur Sicherheit)
|
||||
|
||||
### Plesk: Firewall-Hinweis
|
||||
|
||||
Der CRM-Server muss den **Plesk-Port 8443** (Standard) erreichen können. Bei Plesk-Firewall:
|
||||
|
||||
1. **Tools & Einstellungen** → **Firewall**
|
||||
2. **"Plesk-Dienst – Panel"** (Port 8443) für die IP des CRM-Servers erlauben
|
||||
|
||||
Bei reiner Linux-Firewall (ufw/firewalld):
|
||||
```bash
|
||||
# Beispiel ufw
|
||||
ufw allow from <CRM-SERVER-IP> to any port 8443
|
||||
```
|
||||
|
||||
### Verwendung
|
||||
|
||||
Beim Anlegen einer Stressfrei-Wechseln Adresse im Kundenbereich erscheint die Checkbox **"Beim E-Mail-Provider anlegen"**, wenn:
|
||||
@@ -1228,254 +916,6 @@ Folgende Felder werden in Audit-Logs gefiltert:
|
||||
- API-Response wird nicht blockiert
|
||||
- Before/After-Werte über Prisma Middleware
|
||||
|
||||
## Factory-Defaults: Stammdaten-Kataloge teilen
|
||||
|
||||
Das **Factory-Defaults**-System erlaubt den Export und Import von
|
||||
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
|
||||
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
|
||||
zu Datenbank-Backups:
|
||||
|
||||
### Abgrenzung
|
||||
|
||||
| | Factory-Defaults | Datenbank-Backup |
|
||||
|---|---|---|
|
||||
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
|
||||
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
|
||||
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
|
||||
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
|
||||
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
|
||||
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
|
||||
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
|
||||
|
||||
> **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
|
||||
> Backup = die komplette Instanz.
|
||||
|
||||
### 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
|
||||
2. Button **„Factory-Defaults exportieren"** klicken
|
||||
3. 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:**
|
||||
```
|
||||
factory-defaults-2026-05-07-1949.zip
|
||||
├── manifest.json # Version + Datum + Counts
|
||||
├── providers/providers.json
|
||||
├── contract-meta/
|
||||
│ ├── cancellation-periods.json
|
||||
│ ├── contract-durations.json
|
||||
│ └── contract-categories.json
|
||||
├── pdf-templates/
|
||||
│ ├── pdf-templates.json
|
||||
│ └── *.pdf # Die eigentlichen PDF-Dateien
|
||||
└── app-settings/
|
||||
└── app-settings.json # HTML-Templates (Whitelist-only)
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
**Variante A – UI:**
|
||||
1. **Einstellungen** → **Factory-Defaults**
|
||||
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
|
||||
3. Erfolgs-Box zeigt Counts pro Kategorie
|
||||
|
||||
**Variante B – CLI:**
|
||||
```bash
|
||||
./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:**
|
||||
```
|
||||
✓ Anbieter: 10
|
||||
✓ Tarife: 4
|
||||
✓ Kündigungsfristen: 18
|
||||
✓ Laufzeiten: 18
|
||||
✓ Vertragskategorien: 8
|
||||
✓ PDF-Vorlagen: 2
|
||||
✓ HTML-Templates: 2
|
||||
```
|
||||
|
||||
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
|
||||
|
||||
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
|
||||
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/
|
||||
providers/
|
||||
verivox.json # 40 Anbieter aus Verivox-Paket
|
||||
check24.json # 30 Anbieter aus Check24-Paket
|
||||
eigene.json # 5 eigene Anbieter
|
||||
```
|
||||
|
||||
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
|
||||
nimmt nur eine ZIP entgegen – für Merges nutze `npm run seed:defaults`.
|
||||
|
||||
### Idempotenz
|
||||
|
||||
Alle Pfade nutzen Prisma `upsert`:
|
||||
- **Neue Einträge** werden angelegt
|
||||
- **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
|
||||
- Nichts wird gelöscht
|
||||
|
||||
Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
|
||||
oder Duplikate.
|
||||
|
||||
### PDF-Dateien
|
||||
|
||||
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
|
||||
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
|
||||
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
|
||||
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
|
||||
|
||||
| Aktion | Berechtigung |
|
||||
|--------|--------------|
|
||||
| Factory-Defaults Vorschau | `settings:read` |
|
||||
| Factory-Defaults Export (UI/CLI) | `settings:update` |
|
||||
| Factory-Defaults Import (UI/CLI) | `settings:update` |
|
||||
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
|
||||
|
||||
### Typische Einsatzzwecke
|
||||
|
||||
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
|
||||
(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
|
||||
(andere Ordner aus der ZIP entfernen vor dem Entpacken).
|
||||
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
|
||||
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
||||
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
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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,17 +1,9 @@
|
||||
# Backend nutzt seit v1.1 die zentrale Root-.env im Projektverzeichnis.
|
||||
# → siehe ../.env.example für alle Variablen
|
||||
#
|
||||
# Diese Datei bleibt als Legacy-Fallback: wenn /.env nicht existiert,
|
||||
# liest das Backend backend/.env (z.B. für isolierte Backend-Tests).
|
||||
|
||||
# Database
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||
# Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
|
||||
JWT_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Encryption (for portal credentials)
|
||||
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
||||
|
||||
+1
-7
@@ -4,11 +4,10 @@ node_modules/
|
||||
# Build
|
||||
dist/
|
||||
|
||||
# Environment – echte Secrets blocken, .env.example weiter mittracken
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Database Backups (can be large, keep folder structure)
|
||||
prisma/backups/*
|
||||
@@ -18,11 +17,6 @@ prisma/backups/*
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
|
||||
factory-defaults/*
|
||||
!factory-defaults/.gitkeep
|
||||
!factory-defaults/README.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,290 +0,0 @@
|
||||
# Factory Defaults
|
||||
|
||||
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
|
||||
OpenCRM-Installation automatisch eingespielt werden können.
|
||||
|
||||
Siehe auch den Abschnitt „Factory-Defaults" in der Haupt-[README.md](../../README.md)
|
||||
für einen Gesamtüberblick und die Abgrenzung zum Datenbank-Backup.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
```
|
||||
backend/factory-defaults/
|
||||
├── providers/
|
||||
│ └── providers.json # Anbieter inkl. Tarife
|
||||
├── contract-meta/
|
||||
│ ├── cancellation-periods.json # Kündigungsfristen
|
||||
│ ├── contract-durations.json # Vertragslaufzeiten
|
||||
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
||||
├── pdf-templates/
|
||||
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
|
||||
│ └── *.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,
|
||||
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
|
||||
**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)
|
||||
|
||||
1. Im CRM als Admin einloggen
|
||||
2. **Einstellungen** → **Factory-Defaults**
|
||||
3. Auf **„Factory-Defaults exportieren"** klicken
|
||||
4. Die heruntergeladene ZIP (`factory-defaults-YYYY-MM-DD.zip`) speichern
|
||||
|
||||
### Inhalt der ZIP
|
||||
|
||||
```
|
||||
factory-defaults-2026-04-23.zip
|
||||
├── manifest.json # Version, Datum, Einträge pro Kategorie
|
||||
├── providers/providers.json
|
||||
├── contract-meta/cancellation-periods.json
|
||||
├── contract-meta/contract-durations.json
|
||||
├── contract-meta/contract-categories.json
|
||||
├── pdf-templates/pdf-templates.json
|
||||
├── pdf-templates/*.pdf
|
||||
└── app-settings/app-settings.json
|
||||
```
|
||||
|
||||
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
||||
neue Installationen oder Partner-Setups.
|
||||
|
||||
---
|
||||
|
||||
## Import (in eine andere Installation)
|
||||
|
||||
### 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)
|
||||
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
||||
Unterordnerstruktur beibehalten
|
||||
3. **Script ausführen:**
|
||||
```bash
|
||||
cd backend
|
||||
npm run seed:defaults
|
||||
```
|
||||
4. **Ausgabe prüfen** – bei Erfolg:
|
||||
```
|
||||
📦 Factory-Defaults werden eingespielt...
|
||||
|
||||
✓ Anbieter: 7, Tarife: 12
|
||||
✓ Kündigungsfristen: 5
|
||||
✓ Laufzeiten: 4
|
||||
✓ Vertragskategorien: 8
|
||||
✓ PDF-Vorlagen: 3
|
||||
|
||||
✅ Factory-Defaults erfolgreich eingespielt.
|
||||
```
|
||||
|
||||
### Idempotenz
|
||||
|
||||
Das Script nutzt ausschließlich `upsert`:
|
||||
- **Neue Einträge** werden angelegt
|
||||
- **Bestehende Einträge** (match per unique key: `name` / `code`) werden aktualisiert
|
||||
- Nichts wird gelöscht
|
||||
|
||||
Du kannst `npm run seed:defaults` **beliebig oft ausführen** – kein Datenverlust,
|
||||
keine Duplikate.
|
||||
|
||||
### Was passiert mit den PDF-Dateien?
|
||||
|
||||
Die PDFs aus `pdf-templates/*.pdf` werden beim Import nach
|
||||
`backend/uploads/pdf-templates/` kopiert (mit eindeutigem Zeitstempel-Suffix).
|
||||
Die Pfade in der DB werden automatisch auf die neue Kopie gesetzt.
|
||||
|
||||
Beim Re-Import einer bereits existierenden Vorlage wird die alte Datei in `uploads/`
|
||||
entsorgt und durch die neue ersetzt.
|
||||
|
||||
---
|
||||
|
||||
## Mehrere Exporte mergen
|
||||
|
||||
Wenn du mehrere ZIPs hast (z.B. "Verivox-Paket", "Check24-Paket", "eigene"), kannst
|
||||
du die JSON-Dateien frei benennen und in einen Ordner legen. Das Script liest
|
||||
alle `*.json` im jeweiligen Unterordner und merged den Inhalt zusammen.
|
||||
|
||||
**Beispiel:**
|
||||
```
|
||||
backend/factory-defaults/
|
||||
providers/
|
||||
verivox.json # 40 Anbieter aus dem Verivox-Paket
|
||||
check24.json # 30 Anbieter aus dem Check24-Paket
|
||||
eigene.json # 5 eigene, firmenspezifische Anbieter
|
||||
contract-meta/
|
||||
standard.json # Standard-Kündigungsfristen + Laufzeiten + Kategorien
|
||||
pdf-templates/
|
||||
ewe-paket.json # EWE-Vorlage
|
||||
moon-paket.json # Moon-Vorlage
|
||||
ewe-auftrag.pdf
|
||||
moon-formular.pdf
|
||||
```
|
||||
|
||||
Bei gleichem Unique-Key (z.B. `providers.name: "EWE"` in mehreren Dateien) gewinnt
|
||||
der zuletzt gelesene Eintrag.
|
||||
|
||||
---
|
||||
|
||||
## Teil-Import (nur Kategorien auswählen)
|
||||
|
||||
Falls du nur einen Teil importieren willst (z.B. nur PDF-Vorlagen ohne Anbieter),
|
||||
lösche oder verschiebe einfach die nicht gewünschten JSON-Dateien, bevor du das
|
||||
Script ausführst. Das Script überspringt Kategorien ohne Dateien ohne Fehler.
|
||||
|
||||
**Beispiel: nur PDF-Vorlagen:**
|
||||
```
|
||||
backend/factory-defaults/
|
||||
pdf-templates/ # nur diesen Ordner behalten
|
||||
pdf-templates.json
|
||||
*.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struktur-Referenz (für manuelle Pflege)
|
||||
|
||||
### `providers/providers.json`
|
||||
|
||||
Array von Providern, jeweils inkl. zugehöriger Tarife:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "EWE",
|
||||
"portalUrl": "https://www.ewe.de/privatkunden/meine-ewe/login",
|
||||
"usernameFieldName": "username",
|
||||
"passwordFieldName": "password",
|
||||
"isActive": true,
|
||||
"tariffs": [
|
||||
{ "name": "EWE Zuhause Strom", "isActive": true },
|
||||
{ "name": "EWE Zuhause Gas", "isActive": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Unique Key:** `name`
|
||||
|
||||
### `contract-meta/cancellation-periods.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "code": "14T", "description": "14 Tage", "isActive": true },
|
||||
{ "code": "1M", "description": "1 Monat", "isActive": true },
|
||||
{ "code": "3M", "description": "3 Monate", "isActive": true }
|
||||
]
|
||||
```
|
||||
|
||||
**Unique Key:** `code`
|
||||
|
||||
### `contract-meta/contract-durations.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "code": "12M", "description": "12 Monate", "isActive": true },
|
||||
{ "code": "24M", "description": "24 Monate", "isActive": true }
|
||||
]
|
||||
```
|
||||
|
||||
**Unique Key:** `code`
|
||||
|
||||
### `contract-meta/contract-categories.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "ELECTRICITY",
|
||||
"name": "Strom",
|
||||
"icon": "Zap",
|
||||
"color": "#FFC107",
|
||||
"sortOrder": 1,
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Unique Key:** `code`
|
||||
|
||||
### `pdf-templates/pdf-templates.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "EWE Auftragsformular",
|
||||
"description": "Auftrag für Glasfaser-Anschluss",
|
||||
"providerName": "EWE",
|
||||
"originalName": "EWE-Auftrag-Privat.pdf",
|
||||
"fieldMapping": {
|
||||
"Vorname": "customer.firstName",
|
||||
"Nachname": "customer.lastName",
|
||||
"Strasse": "address.streetFull",
|
||||
"PLZ": "address.postalCode",
|
||||
"Ort": "address.city"
|
||||
},
|
||||
"phoneFieldPrefix": "Rufnummer",
|
||||
"maxPhoneFields": 8,
|
||||
"isActive": true,
|
||||
"pdfFilename": "EWE_Auftragsformular.pdf"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Unique Key:** `name`
|
||||
**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
|
||||
|
||||
| Aktion | Berechtigung |
|
||||
|--------|--------------|
|
||||
| Factory-Defaults Vorschau | `settings:read` |
|
||||
| Factory-Defaults Export (UI) | `settings:update` |
|
||||
| Factory-Defaults Import (UI) | `settings:update` |
|
||||
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
||||
|
||||
---
|
||||
|
||||
## Git & Versionierung
|
||||
|
||||
Dieser Ordner ist in `.gitignore` eingetragen (außer `.gitkeep` und `README.md`),
|
||||
damit firmen-spezifische Exporte nicht versehentlich ins Repo kommen.
|
||||
|
||||
Wenn du **öffentlich teilbare Katalog-Pakete** versionieren willst, lege sie
|
||||
außerhalb dieses Ordners ab (z.B. in einem eigenen Repository) und kopiere sie
|
||||
bei Bedarf hierher.
|
||||
Generated
+101
-248
@@ -1,35 +1,28 @@
|
||||
{
|
||||
"name": "opencrm-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencrm-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv-expand": "^13.0.0",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.1.0",
|
||||
"imapflow": "^1.2.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.9.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"tsx": "^4.19.2",
|
||||
"undici": "^6.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,10 +35,10 @@
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
@@ -56,6 +49,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -71,6 +65,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -86,6 +81,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -101,6 +97,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -116,6 +113,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -131,6 +129,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -146,6 +145,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -161,6 +161,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -176,6 +177,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -191,6 +193,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -206,6 +209,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -221,6 +225,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -236,6 +241,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -251,6 +257,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -266,6 +273,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -281,6 +289,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -296,6 +305,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -311,6 +321,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -326,6 +337,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -341,6 +353,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -356,6 +369,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -371,6 +385,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -386,6 +401,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -401,6 +417,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -416,6 +433,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -431,6 +449,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -488,8 +507,7 @@
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -610,6 +628,7 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
@@ -619,19 +638,11 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@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": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -645,6 +656,7 @@
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -656,6 +668,7 @@
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
@@ -666,7 +679,8 @@
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"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": {
|
||||
"version": "9.0.10",
|
||||
@@ -703,7 +717,8 @@
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
@@ -724,17 +739,11 @@
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
@@ -756,12 +765,14 @@
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"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": {
|
||||
"version": "1.2.7",
|
||||
"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": {
|
||||
"version": "1.1.5",
|
||||
@@ -776,6 +787,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -784,6 +796,7 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
@@ -794,6 +807,7 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
@@ -961,7 +975,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -1045,10 +1058,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
@@ -1251,25 +1263,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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -1456,33 +1449,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1577,6 +1543,7 @@
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
@@ -1695,24 +1662,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz",
|
||||
"integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||
@@ -1803,6 +1752,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1859,6 +1809,7 @@
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
@@ -1931,15 +1882,6 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
@@ -2023,31 +1965,19 @@
|
||||
]
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
|
||||
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
|
||||
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.9",
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.7",
|
||||
"pino": "10.3.1",
|
||||
"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"
|
||||
"nodemailer": "7.0.13",
|
||||
"pino": "10.3.0",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||
@@ -2065,27 +1995,6 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2278,10 +2187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
@@ -2324,19 +2232,18 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
||||
"license": "MIT",
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
||||
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.8",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"nodemailer": "7.0.13",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
@@ -2356,27 +2263,6 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2440,12 +2326,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2512,15 +2397,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
@@ -2560,7 +2436,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -2630,10 +2505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
@@ -2680,10 +2554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
@@ -2705,7 +2578,6 @@
|
||||
"version": "3.0.0",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
@@ -2713,8 +2585,7 @@
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
@@ -2766,8 +2637,7 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
]
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
@@ -2790,10 +2660,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -2807,8 +2676,7 @@
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
@@ -2860,10 +2728,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
@@ -2875,7 +2742,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
@@ -2884,6 +2750,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
@@ -2916,7 +2783,6 @@
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -3097,19 +2963,17 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
|
||||
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
|
||||
"license": "MIT",
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"ip-address": "^10.0.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3117,20 +2981,10 @@
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
@@ -3139,7 +2993,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
@@ -3293,7 +3146,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
@@ -3331,6 +3183,7 @@
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -3381,10 +3234,9 @@
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||
"license": "MIT",
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
@@ -3392,7 +3244,8 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
|
||||
+4
-13
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "opencrm-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenCRM Backend API",
|
||||
"main": "dist/index.js",
|
||||
"prisma": {
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
@@ -12,36 +12,27 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"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:studio": "prisma studio",
|
||||
"db:backup": "tsx prisma/backup-data.ts",
|
||||
"db:restore": "tsx prisma/restore-data.ts",
|
||||
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
|
||||
"db:restore": "tsx prisma/restore-data.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv-expand": "^13.0.0",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.1.0",
|
||||
"imapflow": "^1.2.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.9.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"tsx": "^4.19.2",
|
||||
"undici": "^6.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,10 +45,10 @@
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* Datenbank-Backup Script
|
||||
*
|
||||
* Exportiert ALLE Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
|
||||
* Exportiert alle Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
|
||||
*
|
||||
* Verwendung:
|
||||
* npm run db:backup
|
||||
* npx ts-node prisma/backup-data.ts
|
||||
*
|
||||
* Erstellt einen Ordner 'prisma/backups/YYYY-MM-DDTHH-mm-ss/' mit JSON-Dateien pro Tabelle.
|
||||
*
|
||||
* Die Tabellen sind nach Abhängigkeitsreihenfolge sortiert (Level 0 = keine FKs, dann aufsteigend).
|
||||
* Damit kann das Restore-Script sie in der gleichen Reihenfolge einspielen, ohne FK-Verletzungen.
|
||||
* Erstellt einen Ordner 'backups/YYYY-MM-DD_HH-mm-ss/' mit JSON-Dateien pro Tabelle.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
@@ -31,7 +28,7 @@ async function main() {
|
||||
|
||||
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
|
||||
const tables = [
|
||||
// ============ Level 0: Reine Stammdaten/Kataloge ============
|
||||
// Level 0: Keine Abhängigkeiten
|
||||
{ name: 'Permission', query: () => prisma.permission.findMany() },
|
||||
{ name: 'Role', query: () => prisma.role.findMany() },
|
||||
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
|
||||
@@ -40,58 +37,40 @@ async function main() {
|
||||
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
|
||||
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
|
||||
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
|
||||
{ name: 'Provider', query: () => prisma.provider.findMany() },
|
||||
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
|
||||
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
||||
{ name: 'EnergyProvider', query: () => prisma.energyProvider.findMany() },
|
||||
{ name: 'TelecomProvider', query: () => prisma.telecomProvider.findMany() },
|
||||
|
||||
// ============ Level 1: Abhängig von Level 0 ============
|
||||
// Level 1: Abhängig von Level 0
|
||||
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
|
||||
{ name: 'User', query: () => prisma.user.findMany() },
|
||||
{ name: 'Customer', query: () => prisma.customer.findMany() },
|
||||
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
|
||||
|
||||
// ============ Level 2: Abhängig von Customer ============
|
||||
// Level 2: Abhängig von Level 1
|
||||
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
|
||||
{ name: 'Address', query: () => prisma.address.findMany() },
|
||||
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
|
||||
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
|
||||
{ name: 'Meter', query: () => prisma.meter.findMany() },
|
||||
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
|
||||
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
|
||||
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
|
||||
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
|
||||
|
||||
// ============ Level 3: Contracts + abhängige ============
|
||||
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
|
||||
{ name: 'Contract', query: () => prisma.contract.findMany() },
|
||||
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
|
||||
{ name: 'Meter', query: () => prisma.meter.findMany() },
|
||||
|
||||
// ============ Level 4: Vertragstyp-Details + Sub-Tabellen ============
|
||||
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
|
||||
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
|
||||
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
|
||||
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
|
||||
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
|
||||
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
|
||||
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
|
||||
{ name: 'ContractHistoryEntry', query: () => prisma.contractHistoryEntry.findMany() },
|
||||
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
|
||||
{ name: 'Invoice', query: () => prisma.invoice.findMany() },
|
||||
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
|
||||
|
||||
// ============ Level 5: Sub-Tabellen der Sub-Tabellen ============
|
||||
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
|
||||
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
|
||||
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
|
||||
|
||||
// ============ Level 6: Logs & Emails (wachsende Tabellen) ============
|
||||
// Level 3: Abhängig von Level 2
|
||||
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
|
||||
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
||||
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
|
||||
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
|
||||
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
|
||||
{ name: 'ContractNote', query: () => prisma.contractNote.findMany() },
|
||||
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
|
||||
|
||||
// Level 4: Abhängig von Level 3
|
||||
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
|
||||
|
||||
// Vertragstyp-spezifische Details
|
||||
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
|
||||
{ name: 'TelecomContractDetails', query: () => prisma.telecomContractDetails.findMany() },
|
||||
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
|
||||
];
|
||||
|
||||
let totalRecords = 0;
|
||||
const stats: { table: string; count: number }[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
@@ -100,7 +79,7 @@ async function main() {
|
||||
totalRecords += count;
|
||||
stats.push({ table: table.name, count });
|
||||
|
||||
// JSON-Datei schreiben (Date-Felder als ISO-String)
|
||||
// JSON-Datei schreiben
|
||||
const filePath = path.join(backupDir, `${table.name}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -108,26 +87,20 @@ async function main() {
|
||||
console.log(`${status} ${table.name}: ${count} Einträge`);
|
||||
} catch (error: any) {
|
||||
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
|
||||
skipped.push(table.name);
|
||||
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 80)}...)`);
|
||||
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 50)}...)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Backup-Info speichern
|
||||
const backupInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
schemaVersion: 'current',
|
||||
totalRecords,
|
||||
tables: stats,
|
||||
skippedTables: skipped,
|
||||
};
|
||||
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
|
||||
|
||||
console.log(`\n✅ Backup abgeschlossen!`);
|
||||
console.log(` 📊 ${totalRecords} Datensätze in ${stats.filter(s => s.count > 0).length} Tabellen`);
|
||||
if (skipped.length > 0) {
|
||||
console.log(` ⚠️ ${skipped.length} Tabellen übersprungen: ${skipped.join(', ')}`);
|
||||
}
|
||||
console.log(` 📁 Gespeichert in: ${backupDir}\n`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -1,989 +0,0 @@
|
||||
-- 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;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `BankCard` ADD COLUMN `documentPath` VARCHAR(191) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `IdentityDocument` ADD COLUMN `documentPath` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Customer` ADD COLUMN `birthDate` DATETIME(3) NULL,
|
||||
ADD COLUMN `birthPlace` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `IdentityDocument` ADD COLUMN `licenseClasses` VARCHAR(191) NULL,
|
||||
ADD COLUMN `licenseIssueDate` DATETIME(3) NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
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
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 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
@@ -0,0 +1,8 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `User` ADD COLUMN `tokenInvalidatedAt` DATETIME(3) NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `EnergyContractDetails` ADD COLUMN `annualConsumptionKwh` DOUBLE NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `EnergyContractDetails` ADD COLUMN `maloId` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Contract` ADD COLUMN `nextReviewDate` DATETIME(3) NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Contract` ADD COLUMN `contractNumberAtProvider` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 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`);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "mysql"
|
||||
provider = "mysql"
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
+373
-144
@@ -4,40 +4,43 @@
|
||||
* Stellt Daten aus einem JSON-Backup wieder her.
|
||||
*
|
||||
* Verwendung:
|
||||
* npm run db:restore # Letztes Backup
|
||||
* npx tsx prisma/restore-data.ts <ordner> # Bestimmtes Backup
|
||||
* npx ts-node prisma/restore-data.ts [backup-ordner]
|
||||
*
|
||||
* WICHTIG: Führe vorher 'npx prisma db push' oder 'npx prisma migrate deploy' aus,
|
||||
* damit das Schema zur DB passt!
|
||||
* Beispiele:
|
||||
* npx ts-node prisma/restore-data.ts # Letztes Backup
|
||||
* npx ts-node prisma/restore-data.ts 2025-01-31_14-30-00 # Bestimmtes Backup
|
||||
*
|
||||
* WICHTIG: Führe vorher 'npx prisma migrate deploy' oder 'npx prisma db push' aus!
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Hilfsfunktion: JSON-Datei lesen (leer bei fehlender Datei)
|
||||
// Hilfsfunktion: JSON-Datei lesen
|
||||
function readJsonFile<T>(filePath: string): T[] {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
// Hilfsfunktion: ISO-Datum-Strings rekursiv zu Date-Objekten
|
||||
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
|
||||
function convertDates(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj === 'string') {
|
||||
// ISO-Datumsformat erkennen
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
||||
return new Date(obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (Array.isArray(obj)) return obj.map(convertDates);
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convertDates);
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
@@ -48,75 +51,19 @@ function convertDates(obj: any): any {
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer Restore-Helper: nutzt createMany mit skipDuplicates
|
||||
* wenn möglich, sonst einzelnes upsert per ID.
|
||||
*/
|
||||
async function restoreTable<T extends { id?: any }>(
|
||||
tableName: string,
|
||||
data: T[],
|
||||
model: any,
|
||||
options: { useCreateMany?: boolean; compositeKey?: string[] } = {},
|
||||
): Promise<number> {
|
||||
if (data.length === 0) return 0;
|
||||
|
||||
const converted = data.map(convertDates) as T[];
|
||||
|
||||
// Bei einfachen Tabellen: createMany mit skipDuplicates
|
||||
if (options.useCreateMany) {
|
||||
try {
|
||||
const result = await model.createMany({
|
||||
data: converted,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return result.count;
|
||||
} catch (err: any) {
|
||||
// Fallback auf einzeln
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert per ID (oder Composite-Key)
|
||||
let count = 0;
|
||||
for (const item of converted) {
|
||||
try {
|
||||
if (options.compositeKey) {
|
||||
const where: any = {};
|
||||
const compositeWhere: any = {};
|
||||
for (const key of options.compositeKey) {
|
||||
compositeWhere[key] = (item as any)[key];
|
||||
}
|
||||
where[options.compositeKey.join('_')] = compositeWhere;
|
||||
await model.upsert({
|
||||
where,
|
||||
update: {},
|
||||
create: item,
|
||||
});
|
||||
} else {
|
||||
await model.upsert({
|
||||
where: { id: (item as any).id },
|
||||
update: item,
|
||||
create: item,
|
||||
});
|
||||
}
|
||||
count++;
|
||||
} catch (err: any) {
|
||||
console.log(` ⚠️ Eintrag in ${tableName} (id=${(item as any).id}): ${err.message?.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Backup-Ordner bestimmen
|
||||
const backupsDir = path.join(__dirname, 'backups');
|
||||
let backupName = process.argv[2];
|
||||
|
||||
if (!backupName) {
|
||||
// Neuestes Backup finden
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
console.error('❌ Kein Backup-Ordner gefunden!');
|
||||
process.exit(1);
|
||||
}
|
||||
const backups = fs.readdirSync(backupsDir)
|
||||
.filter((f) => fs.statSync(path.join(backupsDir, f)).isDirectory())
|
||||
.filter(f => fs.statSync(path.join(backupsDir, f)).isDirectory())
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
@@ -129,16 +76,18 @@ async function main() {
|
||||
}
|
||||
|
||||
const backupDir = path.join(backupsDir, backupName);
|
||||
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Backup-Info lesen
|
||||
const infoPath = path.join(backupDir, '_backup-info.json');
|
||||
if (fs.existsSync(infoPath)) {
|
||||
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
||||
console.log(`\n📅 Backup vom: ${new Date(info.timestamp).toLocaleString('de-DE')}`);
|
||||
console.log(`📊 ${info.totalRecords} Datensätze\n`);
|
||||
console.log(`📊 ${info.totalRecords} Datensätze in ${info.tables.filter((t: any) => t.count > 0).length} Tabellen\n`);
|
||||
}
|
||||
|
||||
console.log(`🔄 Starte Wiederherstellung aus: ${backupDir}\n`);
|
||||
@@ -147,76 +96,362 @@ async function main() {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
try {
|
||||
// Tabellen in Abhängigkeitsreihenfolge (gleich wie im Backup)
|
||||
const order: Array<{
|
||||
name: string;
|
||||
model: any;
|
||||
compositeKey?: string[];
|
||||
}> = [
|
||||
// Level 0
|
||||
{ name: 'Permission', model: prisma.permission },
|
||||
{ name: 'Role', model: prisma.role },
|
||||
{ name: 'SalesPlatform', model: prisma.salesPlatform },
|
||||
{ name: 'ContractCategory', model: prisma.contractCategory },
|
||||
{ name: 'CancellationPeriod', model: prisma.cancellationPeriod },
|
||||
{ name: 'ContractDuration', model: prisma.contractDuration },
|
||||
{ name: 'AppSetting', model: prisma.appSetting },
|
||||
{ name: 'EmailProviderConfig', model: prisma.emailProviderConfig },
|
||||
{ name: 'Provider', model: prisma.provider },
|
||||
{ name: 'PdfTemplate', model: prisma.pdfTemplate },
|
||||
{ name: 'AuditRetentionPolicy', model: prisma.auditRetentionPolicy },
|
||||
// Tabellen in Abhängigkeitsreihenfolge wiederherstellen
|
||||
const restoreOrder = [
|
||||
// Level 0: Keine Abhängigkeiten
|
||||
{
|
||||
name: 'Permission',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.permission.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Role',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.role.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SalesPlatform',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.salesPlatform.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ContractCategory',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractCategory.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CancellationPeriod',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.cancellationPeriod.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ContractDuration',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractDuration.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AppSetting',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.appSetting.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'EmailProviderConfig',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.emailProviderConfig.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'EnergyProvider',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.energyProvider.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'TelecomProvider',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.telecomProvider.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Level 1
|
||||
{ name: 'RolePermission', model: prisma.rolePermission, compositeKey: ['roleId', 'permissionId'] },
|
||||
{ name: 'User', model: prisma.user },
|
||||
{ name: 'Customer', model: prisma.customer },
|
||||
{ name: 'Tariff', model: prisma.tariff },
|
||||
{
|
||||
name: 'RolePermission',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
|
||||
update: {},
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.user.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Customer',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.customer.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Tariff',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.tariff.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Level 2: Customer-abhängig
|
||||
{ name: 'UserRole', model: prisma.userRole, compositeKey: ['userId', 'roleId'] },
|
||||
{ name: 'Address', model: prisma.address },
|
||||
{ name: 'BankCard', model: prisma.bankCard },
|
||||
{ name: 'IdentityDocument', model: prisma.identityDocument },
|
||||
{ name: 'Meter', model: prisma.meter },
|
||||
{ name: 'StressfreiEmail', model: prisma.stressfreiEmail },
|
||||
{ name: 'CustomerRepresentative', model: prisma.customerRepresentative },
|
||||
{ name: 'CustomerConsent', model: prisma.customerConsent },
|
||||
{ name: 'DataDeletionRequest', model: prisma.dataDeletionRequest },
|
||||
// Level 2
|
||||
{
|
||||
name: 'UserRole',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
|
||||
update: {},
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CustomerRepresentative',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.customerRepresentative.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'StressfreiEmail',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.stressfreiEmail.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Contract',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contract.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Meter',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.meter.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Level 3: Contracts
|
||||
{ name: 'Contract', model: prisma.contract },
|
||||
{ name: 'RepresentativeAuthorization', model: prisma.representativeAuthorization },
|
||||
// Level 3
|
||||
{
|
||||
name: 'CachedEmail',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.cachedEmail.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ContractTask',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractTask.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MeterReading',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.meterReading.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ContractNote',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractNote.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ContractDocument',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractDocument.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Level 4: Vertragstyp-Details
|
||||
{ name: 'EnergyContractDetails', model: prisma.energyContractDetails },
|
||||
{ name: 'InternetContractDetails', model: prisma.internetContractDetails },
|
||||
{ name: 'MobileContractDetails', model: prisma.mobileContractDetails },
|
||||
{ name: 'TvContractDetails', model: prisma.tvContractDetails },
|
||||
{ name: 'CarInsuranceDetails', model: prisma.carInsuranceDetails },
|
||||
{ name: 'ContractMeter', model: prisma.contractMeter },
|
||||
{ name: 'ContractDocument', model: prisma.contractDocument },
|
||||
{ name: 'ContractHistoryEntry', model: prisma.contractHistoryEntry },
|
||||
{ name: 'ContractTask', model: prisma.contractTask },
|
||||
{ name: 'Invoice', model: prisma.invoice },
|
||||
{ name: 'MeterReading', model: prisma.meterReading },
|
||||
// Level 4
|
||||
{
|
||||
name: 'ContractTaskSubtask',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.contractTaskSubtask.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Level 5: Sub-Tabellen
|
||||
{ name: 'ContractTaskSubtask', model: prisma.contractTaskSubtask },
|
||||
{ name: 'PhoneNumber', model: prisma.phoneNumber },
|
||||
{ name: 'SimCard', model: prisma.simCard },
|
||||
|
||||
// Level 6: Logs & Emails
|
||||
{ name: 'CachedEmail', model: prisma.cachedEmail },
|
||||
{ name: 'EmailLog', model: prisma.emailLog },
|
||||
{ name: 'AuditLog', model: prisma.auditLog },
|
||||
// Vertragsdetails
|
||||
{
|
||||
name: 'EnergyContractDetails',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.energyContractDetails.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'TelecomContractDetails',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.telecomContractDetails.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CarInsuranceDetails',
|
||||
restore: async (data: any[]) => {
|
||||
for (const item of data) {
|
||||
await prisma.carInsuranceDetails.upsert({
|
||||
where: { id: item.id },
|
||||
update: convertDates(item),
|
||||
create: convertDates(item),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let totalRestored = 0;
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const table of order) {
|
||||
for (const table of restoreOrder) {
|
||||
const filePath = path.join(backupDir, `${table.name}.json`);
|
||||
const data = readJsonFile<any>(filePath);
|
||||
const data = readJsonFile(filePath);
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log(`⚪ ${table.name}: Keine Daten`);
|
||||
@@ -224,23 +459,17 @@ async function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await restoreTable(table.name, data, table.model, {
|
||||
compositeKey: table.compositeKey,
|
||||
});
|
||||
totalRestored += count;
|
||||
console.log(`✅ ${table.name}: ${count}/${data.length} Einträge wiederhergestellt`);
|
||||
await table.restore(data);
|
||||
totalRestored += data.length;
|
||||
console.log(`✅ ${table.name}: ${data.length} Einträge wiederhergestellt`);
|
||||
} catch (error: any) {
|
||||
skipped.push(table.name);
|
||||
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 100)}`);
|
||||
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
|
||||
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt`);
|
||||
if (skipped.length > 0) {
|
||||
console.log(` ⚠️ ${skipped.length} Tabellen mit Fehlern: ${skipped.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt\n`);
|
||||
|
||||
} finally {
|
||||
// Foreign Key Checks wieder aktivieren
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
@@ -78,10 +78,6 @@ model User {
|
||||
isActive Boolean @default(true)
|
||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
||||
|
||||
// Passwort-Reset
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
|
||||
// Messaging-Kanäle (für Datenschutz-Link-Versand)
|
||||
whatsappNumber String?
|
||||
telegramUsername String?
|
||||
@@ -167,16 +163,6 @@ model Customer {
|
||||
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
||||
portalLastLogin DateTime? // Letzte Anmeldung
|
||||
|
||||
// Portal Passwort-Reset
|
||||
portalPasswordResetToken String? @unique
|
||||
portalPasswordResetExpiresAt DateTime?
|
||||
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
|
||||
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)
|
||||
lastBirthdayGreetingYear Int?
|
||||
|
||||
@@ -372,10 +358,6 @@ model EmailProviderConfig {
|
||||
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
|
||||
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
|
||||
|
||||
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
|
||||
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
|
||||
customerEmailLabel String?
|
||||
|
||||
isActive Boolean @default(true)
|
||||
isDefault Boolean @default(false) // Standard-Provider
|
||||
createdAt DateTime @default(now())
|
||||
@@ -1117,80 +1099,3 @@ model AuditRetentionPolicy {
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
+3
-48
@@ -221,41 +221,8 @@ async function main() {
|
||||
|
||||
console.log('Roles created');
|
||||
|
||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||
// 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);
|
||||
// Create admin user
|
||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
||||
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@admin.com' },
|
||||
@@ -271,19 +238,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
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('========================================================');
|
||||
console.log('Admin user created: admin@admin.com / admin');
|
||||
|
||||
// Create some sales platforms
|
||||
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
/**
|
||||
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
|
||||
*
|
||||
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
|
||||
* - Merged mehrere Dateien pro Kategorie automatisch
|
||||
* - Nutzt Prisma upsert → idempotent, kann mehrfach aufgerufen werden
|
||||
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
|
||||
*
|
||||
* Aufruf:
|
||||
* npm run seed:defaults
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 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 PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
|
||||
|
||||
interface ProviderDef {
|
||||
name: string;
|
||||
portalUrl?: string | null;
|
||||
usernameFieldName?: string | null;
|
||||
passwordFieldName?: string | null;
|
||||
isActive?: boolean;
|
||||
tariffs?: { name: string; isActive?: boolean }[];
|
||||
}
|
||||
|
||||
interface CancellationPeriodDef {
|
||||
code: string;
|
||||
description: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface ContractDurationDef {
|
||||
code: string;
|
||||
description: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface ContractCategoryDef {
|
||||
code: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface PdfTemplateDef {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
providerName?: string | null;
|
||||
originalName: string;
|
||||
fieldMapping: any;
|
||||
phoneFieldPrefix?: string | null;
|
||||
maxPhoneFields?: number | null;
|
||||
isActive?: boolean;
|
||||
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.
|
||||
*/
|
||||
function readJsonArrays<T>(dir: string): T[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
const result: T[] = [];
|
||||
for (const f of files) {
|
||||
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
result.push(...data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`⚠ Konnte ${f} nicht parsen – überspringe.`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
|
||||
*/
|
||||
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
map.set(keyFn(item), item);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
async function seedProviders() {
|
||||
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
|
||||
if (items.length === 0) {
|
||||
console.log(' providers/ – keine Einträge gefunden');
|
||||
return;
|
||||
}
|
||||
|
||||
let providerCount = 0;
|
||||
let tariffCount = 0;
|
||||
|
||||
for (const p of items) {
|
||||
const provider = await prisma.provider.upsert({
|
||||
where: { name: p.name },
|
||||
update: {
|
||||
portalUrl: p.portalUrl ?? null,
|
||||
usernameFieldName: p.usernameFieldName ?? null,
|
||||
passwordFieldName: p.passwordFieldName ?? null,
|
||||
isActive: p.isActive ?? true,
|
||||
},
|
||||
create: {
|
||||
name: p.name,
|
||||
portalUrl: p.portalUrl ?? null,
|
||||
usernameFieldName: p.usernameFieldName ?? null,
|
||||
passwordFieldName: p.passwordFieldName ?? null,
|
||||
isActive: p.isActive ?? true,
|
||||
},
|
||||
});
|
||||
providerCount++;
|
||||
|
||||
if (p.tariffs && p.tariffs.length > 0) {
|
||||
for (const t of p.tariffs) {
|
||||
await prisma.tariff.upsert({
|
||||
where: { providerId_name: { providerId: provider.id, name: t.name } },
|
||||
update: { isActive: t.isActive ?? true },
|
||||
create: {
|
||||
providerId: provider.id,
|
||||
name: t.name,
|
||||
isActive: t.isActive ?? true,
|
||||
},
|
||||
});
|
||||
tariffCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
|
||||
}
|
||||
|
||||
async function seedCancellationPeriods() {
|
||||
const items = dedupe(
|
||||
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
|
||||
(i) => i.code,
|
||||
);
|
||||
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
|
||||
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
|
||||
if (relevant.length === 0) {
|
||||
console.log(' cancellation-periods – keine Einträge');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of relevant) {
|
||||
await prisma.cancellationPeriod.upsert({
|
||||
where: { code: c.code },
|
||||
update: { description: c.description, isActive: c.isActive ?? true },
|
||||
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
|
||||
});
|
||||
}
|
||||
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
|
||||
}
|
||||
|
||||
async function seedContractDurations() {
|
||||
const items = dedupe(
|
||||
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
|
||||
(i) => i.code,
|
||||
);
|
||||
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
|
||||
if (relevant.length === 0) {
|
||||
console.log(' contract-durations – keine Einträge');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const d of relevant) {
|
||||
await prisma.contractDuration.upsert({
|
||||
where: { code: d.code },
|
||||
update: { description: d.description, isActive: d.isActive ?? true },
|
||||
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
|
||||
});
|
||||
}
|
||||
console.log(` ✓ Laufzeiten: ${relevant.length}`);
|
||||
}
|
||||
|
||||
async function seedContractCategories() {
|
||||
const items = dedupe(
|
||||
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
|
||||
(i) => i.code,
|
||||
);
|
||||
if (items.length === 0) {
|
||||
console.log(' contract-categories – keine Einträge');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of items) {
|
||||
await prisma.contractCategory.upsert({
|
||||
where: { code: c.code },
|
||||
update: {
|
||||
name: c.name,
|
||||
icon: c.icon ?? null,
|
||||
color: c.color ?? null,
|
||||
sortOrder: c.sortOrder ?? 0,
|
||||
isActive: c.isActive ?? true,
|
||||
},
|
||||
create: {
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
icon: c.icon ?? null,
|
||||
color: c.color ?? null,
|
||||
sortOrder: c.sortOrder ?? 0,
|
||||
isActive: c.isActive ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✓ Vertragskategorien: ${items.length}`);
|
||||
}
|
||||
|
||||
async function seedPdfTemplates() {
|
||||
const items = dedupe(
|
||||
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
|
||||
(t) => t.name,
|
||||
);
|
||||
if (items.length === 0) {
|
||||
console.log(' pdf-templates – keine Einträge');
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload-Verzeichnis sicherstellen
|
||||
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
|
||||
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const t of items) {
|
||||
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
|
||||
if (!fs.existsSync(srcPdf)) {
|
||||
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} – Template "${t.name}" übersprungen`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
|
||||
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
|
||||
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
|
||||
const relativePath = `/uploads/pdf-templates/${destFilename}`;
|
||||
|
||||
fs.copyFileSync(srcPdf, destPdf);
|
||||
|
||||
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
|
||||
|
||||
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
|
||||
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
|
||||
if (existing?.templatePath) {
|
||||
const oldRel = existing.templatePath.startsWith('/uploads/')
|
||||
? existing.templatePath.substring('/uploads/'.length)
|
||||
: existing.templatePath;
|
||||
const oldAbs = path.join(UPLOADS_ROOT, oldRel);
|
||||
if (fs.existsSync(oldAbs)) {
|
||||
try {
|
||||
fs.unlinkSync(oldAbs);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.pdfTemplate.upsert({
|
||||
where: { name: t.name },
|
||||
update: {
|
||||
description: t.description ?? null,
|
||||
providerName: t.providerName ?? null,
|
||||
templatePath: relativePath,
|
||||
originalName: t.originalName,
|
||||
fieldMapping: fieldMappingJson,
|
||||
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
||||
maxPhoneFields: t.maxPhoneFields ?? 8,
|
||||
isActive: t.isActive ?? true,
|
||||
},
|
||||
create: {
|
||||
name: t.name,
|
||||
description: t.description ?? null,
|
||||
providerName: t.providerName ?? null,
|
||||
templatePath: relativePath,
|
||||
originalName: t.originalName,
|
||||
fieldMapping: fieldMappingJson,
|
||||
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
||||
maxPhoneFields: t.maxPhoneFields ?? 8,
|
||||
isActive: t.isActive ?? true,
|
||||
},
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
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() {
|
||||
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
||||
|
||||
if (!fs.existsSync(ROOT)) {
|
||||
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
|
||||
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await seedProviders();
|
||||
await seedCancellationPeriods();
|
||||
await seedContractDurations();
|
||||
await seedContractCategories();
|
||||
await seedPdfTemplates();
|
||||
await seedAppSettings();
|
||||
|
||||
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('\n❌ Fehler beim Einspielen:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -41,22 +41,10 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
||||
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
|
||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||
const oldValue = before?.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));
|
||||
const newValue = String(value);
|
||||
|
||||
await appSettingService.setSetting(key, newValue);
|
||||
|
||||
@@ -90,24 +78,12 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
||||
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
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||
const oldValue = before?.value ?? '-';
|
||||
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
||||
const newValue = String(value);
|
||||
if (oldValue !== newValue) {
|
||||
changes[key] = { von: oldValue, nach: newValue };
|
||||
}
|
||||
|
||||
@@ -1,57 +1,12 @@
|
||||
import { Request, Response, CookieOptions } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import * as authService from '../services/auth.service.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
|
||||
export async function login(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
const ctx = contextFromRequest(req);
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -61,43 +16,20 @@ export async function login(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
// 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);
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} 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({
|
||||
success: false,
|
||||
error: safeLoginError(error),
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Kundenportal-Login
|
||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||
const { email, password } = req.body || {};
|
||||
const ctx = contextFromRequest(req);
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -107,32 +39,11 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
const result = await authService.customerLogin(email, password);
|
||||
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);
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} 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({
|
||||
success: false,
|
||||
error: safeLoginError(error),
|
||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -188,187 +99,6 @@ export async function me(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern (Email + Token per Mail).
|
||||
* Immer 200 OK zurückgeben um Email-Existenz nicht preiszugeben (User-Enumeration-Schutz).
|
||||
*/
|
||||
export async function requestPasswordReset(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { email, userType } = req.body; // userType: 'admin' | 'portal'
|
||||
|
||||
if (!email) {
|
||||
res.status(400).json({ success: false, error: 'E-Mail erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
// Auch bei Fehlern dieselbe Antwort
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset bestätigen (Token + neues Passwort).
|
||||
*/
|
||||
export async function confirmPasswordReset(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token und neues Passwort erforderlich',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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({
|
||||
success: false,
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
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({
|
||||
success: true,
|
||||
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
|
||||
} as ApiResponse);
|
||||
} 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({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||
@@ -381,16 +111,6 @@ export async function register(req: Request, res: Response): Promise<void> {
|
||||
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({
|
||||
email,
|
||||
password,
|
||||
@@ -410,86 +130,3 @@ export async function register(req: Request, res: Response): Promise<void> {
|
||||
} 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,94 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
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
|
||||
* (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix).
|
||||
* Blockt Path-Traversal-Versuche wie "../../etc/passwd".
|
||||
*/
|
||||
function isValidBackupName(name: string): boolean {
|
||||
return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..');
|
||||
}
|
||||
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
|
||||
* GET /api/settings/backups
|
||||
@@ -107,44 +20,19 @@ export async function listBackups(req: Request, res: Response) {
|
||||
* POST /api/settings/backup
|
||||
*/
|
||||
export async function createBackup(req: Request, res: Response) {
|
||||
const start = Date.now();
|
||||
const capture = startLogCapture();
|
||||
try {
|
||||
const result = await backupService.createBackup();
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
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({
|
||||
req, action: 'CREATE', resourceType: 'Backup',
|
||||
label: `Backup ${result.backupName} erstellt`,
|
||||
});
|
||||
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
||||
} 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 });
|
||||
}
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
@@ -153,63 +41,17 @@ export async function createBackup(req: Request, res: Response) {
|
||||
* Backup wiederherstellen
|
||||
* 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) {
|
||||
const start = Date.now();
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name || !isValidBackupName(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 { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.restoreBackup(name);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
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({
|
||||
req, action: 'UPDATE', resourceType: 'Backup',
|
||||
label: `Backup ${name} wiederhergestellt`,
|
||||
@@ -222,35 +64,10 @@ export async function restoreBackup(req: Request, res: Response) {
|
||||
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
|
||||
});
|
||||
} else {
|
||||
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.',
|
||||
});
|
||||
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
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.',
|
||||
});
|
||||
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,8 +79,8 @@ export async function deleteBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name || !isValidBackupName(name)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.deleteBackup(name);
|
||||
@@ -290,8 +107,8 @@ export async function downloadBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name || !isValidBackupName(name)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.createBackupZip(name);
|
||||
@@ -350,22 +167,6 @@ export async function uploadBackup(req: Request, res: Response) {
|
||||
*/
|
||||
export async function factoryReset(req: Request, res: Response) {
|
||||
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();
|
||||
|
||||
if (result.success) {
|
||||
@@ -380,65 +181,6 @@ export async function factoryReset(req: Request, res: Response) {
|
||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
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 });
|
||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,6 @@ import { createAuditLog } from '../services/audit.service.js';
|
||||
*/
|
||||
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
||||
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 future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
||||
|
||||
|
||||
@@ -8,42 +8,20 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||
import { generateEmailPdf } from '../services/pdfService.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
import { DocumentType } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
canAccessCustomer,
|
||||
canAccessContract,
|
||||
canAccessCachedEmail,
|
||||
canAccessStressfreiEmail,
|
||||
} from '../utils/accessControl.js';
|
||||
|
||||
// ==================== 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
|
||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
@@ -56,17 +34,6 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
||||
limit,
|
||||
offset,
|
||||
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);
|
||||
@@ -80,10 +47,9 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
||||
}
|
||||
|
||||
// E-Mails für einen Vertrag abrufen
|
||||
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
@@ -109,11 +75,9 @@ export async function getEmailsForContract(req: AuthRequest, res: Response): Pro
|
||||
// ==================== SINGLE EMAIL ====================
|
||||
|
||||
// Einzelne E-Mail abrufen (mit Body)
|
||||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(id);
|
||||
|
||||
if (!email) {
|
||||
@@ -138,10 +102,9 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// E-Mail als gelesen/ungelesen markieren
|
||||
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function markAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
const { isRead } = req.body;
|
||||
|
||||
if (isRead) {
|
||||
@@ -161,10 +124,9 @@ export async function markAsRead(req: AuthRequest, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// E-Mail Stern umschalten
|
||||
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function toggleStar(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||||
|
||||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||||
@@ -180,12 +142,10 @@ export async function toggleStar(req: AuthRequest, res: Response): Promise<void>
|
||||
// ==================== CONTRACT ASSIGNMENT ====================
|
||||
|
||||
// E-Mail einem Vertrag zuordnen
|
||||
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function assignToContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { contractId } = req.body;
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||||
|
||||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||||
@@ -201,10 +161,9 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
|
||||
}
|
||||
|
||||
// Vertragszuordnung aufheben
|
||||
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||||
|
||||
@@ -219,10 +178,9 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für ein Konto
|
||||
export async function getFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getFolderCounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||
|
||||
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
||||
|
||||
@@ -237,10 +195,9 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
||||
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||||
|
||||
@@ -257,10 +214,9 @@ export async function getContractFolderCounts(req: AuthRequest, res: Response):
|
||||
// ==================== SYNC & SEND ====================
|
||||
|
||||
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
||||
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function syncAccount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||
const fullSync = req.query.full === 'true';
|
||||
|
||||
// Synchronisiert sowohl INBOX als auch SENT
|
||||
@@ -290,31 +246,12 @@ export async function syncAccount(req: AuthRequest, 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
|
||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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;
|
||||
|
||||
// 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
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
@@ -459,10 +396,9 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
||||
// ==================== ATTACHMENTS ====================
|
||||
|
||||
// Anhang-Liste einer E-Mail abrufen
|
||||
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getAttachments(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.emailId);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
// E-Mail aus Cache laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
@@ -493,14 +429,11 @@ export async function getAttachments(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Einzelnen Anhang herunterladen
|
||||
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.emailId);
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
|
||||
// Portal-Isolation: nur eigene/vertretene Emails
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
// E-Mail aus Cache laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
@@ -567,48 +500,17 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
||||
// 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)}"`);
|
||||
// Datei senden - inline (öffnen) oder attachment (download)
|
||||
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
res.setHeader('Content-Type', attachment.contentType);
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
||||
res.setHeader('Content-Length', attachment.size);
|
||||
res.send(attachment.content);
|
||||
} catch (error) {
|
||||
console.error('downloadAttachment error:', error);
|
||||
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
const lower = rawMsg.toLowerCase();
|
||||
|
||||
let friendly = rawMsg;
|
||||
if (lower.includes('socket disconnected') && lower.includes('tls')) {
|
||||
friendly =
|
||||
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
|
||||
} else if (lower.includes('econnrefused')) {
|
||||
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
|
||||
} else if (lower.includes('etimedout')) {
|
||||
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
|
||||
} else if (lower.includes('authentication') || lower.includes('auth')) {
|
||||
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
|
||||
error: 'Fehler beim Herunterladen des Anhangs',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -616,10 +518,9 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
// ==================== MAILBOX ACCOUNTS ====================
|
||||
|
||||
// Mailbox-Konten eines Kunden abrufen
|
||||
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
|
||||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||||
|
||||
@@ -634,10 +535,9 @@ export async function getMailboxAccounts(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
|
||||
// Mailbox nachträglich aktivieren
|
||||
export async function enableMailbox(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function enableMailbox(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||||
|
||||
const result = await stressfreiEmailService.enableMailbox(id);
|
||||
|
||||
@@ -660,10 +560,9 @@ export async function enableMailbox(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
|
||||
// Mailbox-Status mit Provider synchronisieren
|
||||
export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function syncMailboxStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||||
|
||||
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
||||
|
||||
@@ -692,10 +591,9 @@ export async function syncMailboxStatus(req: AuthRequest, res: Response): Promis
|
||||
}
|
||||
|
||||
// E-Mail-Thread abrufen
|
||||
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getThread(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const thread = await cachedEmailService.getEmailThread(id);
|
||||
|
||||
@@ -710,13 +608,9 @@ export async function getThread(req: AuthRequest, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||||
export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getMailboxCredentials(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
||||
@@ -751,15 +645,6 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
|
||||
// IMAP/SMTP-Einstellungen laden
|
||||
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({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -787,7 +672,7 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
|
||||
}
|
||||
|
||||
// Ungelesene E-Mails zählen
|
||||
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||||
@@ -795,10 +680,8 @@ export async function getUnreadCount(req: AuthRequest, res: Response): Promise<v
|
||||
let count = 0;
|
||||
|
||||
if (customerId) {
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||||
} else if (contractId) {
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||||
}
|
||||
|
||||
@@ -813,10 +696,9 @@ export async function getUnreadCount(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// E-Mail in Papierkorb verschieben (nur Admin)
|
||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
// Prüfen ob E-Mail existiert
|
||||
const email = await cachedEmailService.getCachedEmailById(id);
|
||||
@@ -851,10 +733,9 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
||||
// ==================== TRASH OPERATIONS ====================
|
||||
|
||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
|
||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||||
|
||||
@@ -869,10 +750,9 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Papierkorb-Anzahl für einen Kunden
|
||||
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getTrashCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
|
||||
const count = await cachedEmailService.getTrashCount(customerId);
|
||||
|
||||
@@ -887,10 +767,9 @@ export async function getTrashCount(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||||
|
||||
@@ -913,10 +792,9 @@ export async function restoreEmail(req: AuthRequest, res: Response): Promise<voi
|
||||
}
|
||||
|
||||
// E-Mail endgültig löschen (aus Papierkorb)
|
||||
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||||
|
||||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||||
|
||||
@@ -941,10 +819,9 @@ export async function permanentDeleteEmail(req: AuthRequest, res: Response): Pro
|
||||
// ==================== ATTACHMENT TARGETS ====================
|
||||
|
||||
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
||||
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
|
||||
// E-Mail mit StressfreiEmail laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
@@ -1124,10 +1001,9 @@ export async function getAttachmentTargets(req: AuthRequest, res: Response): Pro
|
||||
}
|
||||
|
||||
// E-Mail-Anhang in ein Dokumentenfeld speichern
|
||||
export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { entityType, entityId, targetKey } = req.body;
|
||||
|
||||
@@ -1412,10 +1288,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
||||
// ==================== SAVE EMAIL AS PDF ====================
|
||||
|
||||
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
||||
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { entityType, entityId, targetKey } = req.body;
|
||||
|
||||
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
||||
@@ -1660,10 +1535,9 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
|
||||
// ==================== SAVE EMAIL AS INVOICE ====================
|
||||
|
||||
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const { invoiceDate, invoiceType, notes } = req.body;
|
||||
|
||||
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||||
@@ -1705,7 +1579,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertrag laden
|
||||
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
include: { energyDetails: true },
|
||||
@@ -1719,6 +1593,22 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contract.energyDetails) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Empfänger-Adressen parsen (JSON Array)
|
||||
let toAddresses: string[] = [];
|
||||
let ccAddresses: string[] = [];
|
||||
@@ -1755,20 +1645,13 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
||||
// PDF speichern
|
||||
fs.writeFileSync(filePath, pdfBuffer);
|
||||
|
||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||||
const invoice = contract.energyDetails
|
||||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
// Invoice in DB erstellen
|
||||
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1787,10 +1670,9 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
||||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||
|
||||
// E-Mail-Anhang als Rechnung speichern
|
||||
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { invoiceDate, invoiceType, notes } = req.body;
|
||||
|
||||
@@ -1833,7 +1715,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertrag laden
|
||||
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
include: { energyDetails: true },
|
||||
@@ -1847,6 +1729,22 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Nur für Strom- und Gas-Verträge verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contract.energyDetails) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||
if (email.folder === 'SENT' && email.uid === 0) {
|
||||
res.status(400).json({
|
||||
@@ -1918,20 +1816,13 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
// Datei speichern
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||||
const invoice = contract.energyDetails
|
||||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
// Invoice in DB erstellen
|
||||
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||
invoiceDate: new Date(invoiceDate),
|
||||
invoiceType,
|
||||
documentPath: relativePath,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1946,140 +1837,3 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||||
*/
|
||||
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!documentType || typeof documentType !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'documentType ist erforderlich',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.contractId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: email.contractId },
|
||||
select: { id: true, contractNumber: true, customerId: true },
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
|
||||
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
|
||||
if (email.folder === 'SENT' && email.uid === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail für IMAP-Zugangsdaten
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
|
||||
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Mailbox-Zugangsdaten verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await getImapSmtpSettings();
|
||||
if (!settings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||
|
||||
const credentials: ImapCredentials = {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
if (!attachment) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploads-Verzeichnis
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = path.extname(filename) || '.pdf';
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||||
const filePath = path.join(uploadsDir, newFilename);
|
||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||
|
||||
fs.writeFileSync(filePath, attachment.content);
|
||||
|
||||
const doc = await prisma.contractDocument.create({
|
||||
data: {
|
||||
contractId: contract.id,
|
||||
documentType,
|
||||
documentPath: relativePath,
|
||||
originalName: filename,
|
||||
notes: notes || null,
|
||||
uploadedBy: (req as any).user?.email || 'email-import',
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,7 @@ export async function grantAllConsents(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
});
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
||||
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
||||
|
||||
@@ -6,9 +6,6 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -47,15 +44,9 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
||||
page: page ? parseInt(page 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({
|
||||
success: true,
|
||||
data,
|
||||
data: result.contracts,
|
||||
pagination: result.pagination,
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -96,11 +87,7 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const data = isPortal
|
||||
? sanitizeContractStrict(contract as any)
|
||||
: sanitizeContract(contract as any);
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
res.json({ success: true, data: contract } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -109,19 +96,8 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function createContract(req: Request, res: Response): Promise<void> {
|
||||
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);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Contract',
|
||||
@@ -129,9 +105,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
||||
label: `Vertrag ${contract.contractNumber} angelegt`,
|
||||
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);
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -203,13 +177,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
customerId: before?.customerId,
|
||||
});
|
||||
|
||||
// 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);
|
||||
res.json({ success: true, data: contract } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -241,7 +209,6 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
||||
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const previousContractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, previousContractId))) return;
|
||||
|
||||
// Vorgängervertrag laden für Vertragsnummer
|
||||
const previousContract = await prisma.contract.findUnique({
|
||||
@@ -278,9 +245,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
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);
|
||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -289,67 +254,9 @@ 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> {
|
||||
export async function getContractPassword(req: Request, 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> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const password = await contractService.getContractPassword(contractId);
|
||||
const password = await contractService.getContractPassword(parseInt(req.params.id));
|
||||
if (password === null) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -357,14 +264,6 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
|
||||
} as ApiResponse);
|
||||
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);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -374,29 +273,9 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const simCardId = parseInt(req.params.simCardId);
|
||||
// SimCard → MobileDetails → Contract
|
||||
const sim = await prisma.simCard.findUnique({
|
||||
where: { id: simCardId },
|
||||
select: { mobileDetails: { select: { contractId: true } } },
|
||||
});
|
||||
if (!sim?.mobileDetails) {
|
||||
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
|
||||
|
||||
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`,
|
||||
});
|
||||
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -406,20 +285,9 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
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`,
|
||||
});
|
||||
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -429,29 +297,9 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const phoneNumberId = parseInt(req.params.phoneNumberId);
|
||||
// PhoneNumber → InternetDetails → Contract
|
||||
const phone = await prisma.phoneNumber.findUnique({
|
||||
where: { id: phoneNumberId },
|
||||
select: { internetDetails: { select: { contractId: true } } },
|
||||
});
|
||||
if (!phone?.internetDetails) {
|
||||
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
|
||||
|
||||
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`,
|
||||
});
|
||||
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
|
||||
res.json({ success: true, data: credentials } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -465,22 +313,7 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
|
||||
|
||||
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// 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 });
|
||||
const cockpitData = await contractCockpitService.getCockpitData();
|
||||
res.json({ success: true, data: cockpitData } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Cockpit error:', error);
|
||||
@@ -562,7 +395,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
||||
try {
|
||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'ContractMeter',
|
||||
@@ -583,8 +415,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
||||
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const documents = await prisma.contractDocument.findMany({
|
||||
where: { contractId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -598,8 +428,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
|
||||
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { documentType, notes, deliveryDate } = req.body;
|
||||
const { documentType, notes } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||
@@ -632,9 +461,6 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
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);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
@@ -648,7 +474,6 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
||||
try {
|
||||
const documentId = parseInt(req.params.documentId);
|
||||
const contractId = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
||||
if (!doc || doc.contractId !== contractId) {
|
||||
@@ -686,10 +511,9 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
||||
|
||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
||||
|
||||
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function snoozeContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (!(await canAccessContract(req, res, id))) return;
|
||||
const { nextReviewDate, months } = req.body;
|
||||
|
||||
let reviewDate: Date | null = null;
|
||||
|
||||
@@ -2,12 +2,10 @@ import { Request, Response } from 'express';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
||||
res.json({ success: true, data: entries } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -21,7 +19,6 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
|
||||
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
@@ -57,7 +54,6 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
const { title, description } = req.body;
|
||||
|
||||
@@ -84,7 +80,6 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const entryId = parseInt(req.params.entryId);
|
||||
|
||||
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
||||
|
||||
@@ -5,30 +5,19 @@ import * as customerService from '../services/customer.service.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
|
||||
|
||||
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
||||
|
||||
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { status, customerId } = req.query;
|
||||
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
|
||||
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||
let customerPortalCustomerIds: number[] | undefined;
|
||||
let customerPortalEmails: string[] | undefined;
|
||||
|
||||
if (allowedIds) {
|
||||
// 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;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
@@ -37,7 +26,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
const tasks = await contractTaskService.getAllTasks({
|
||||
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
||||
customerId: customerIdNum,
|
||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
||||
customerPortalCustomerIds,
|
||||
customerPortalEmails,
|
||||
});
|
||||
@@ -53,13 +42,12 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||
let customerPortalCustomerIds: number[] | undefined;
|
||||
let customerPortalEmails: string[] | undefined;
|
||||
|
||||
if (allowedIds) {
|
||||
customerPortalCustomerIds = allowedIds;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
@@ -87,17 +75,33 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const { status } = req.query;
|
||||
|
||||
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
|
||||
// hasAuthorization (Pentest Runde 6 – HOCH-04: widerrufene Vollmachten
|
||||
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
|
||||
// konsultiert wurde, ohne Status-Check).
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
// Prüfe Zugriff auf den Vertrag
|
||||
const contract = await contractService.getContractById(contractId);
|
||||
if (!contract) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Vertrag nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
|
||||
// Für Kundenportal: Zugriffsprüfung
|
||||
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;
|
||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
||||
if (allowedIds) {
|
||||
const customers = await customerService.getCustomersByIds(allowedIds);
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
customerPortalEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.filter((email: string | null): email is string => !!email);
|
||||
@@ -183,8 +187,27 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
// Prüfe Zugriff auf den Vertrag
|
||||
const contract = await contractService.getContractById(contractId);
|
||||
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;
|
||||
|
||||
@@ -353,7 +376,24 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.user?.isCustomerPortal || !req.user.customerId) {
|
||||
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
const allowedEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.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);
|
||||
if (!task.visibleInPortal && !isOwnTask) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diese Anfrage',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Nur für Kundenportal-Benutzer',
|
||||
@@ -361,27 +401,6 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
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 customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||
const allowedEmails = customers
|
||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||
.filter((email: string | null): email is string => !!email);
|
||||
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
|
||||
if (!task.visibleInPortal && !isOwnTask) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Kein Zugriff auf diese Anfrage',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdBy = req.user?.email;
|
||||
|
||||
const subtask = await contractTaskService.createSubtask({
|
||||
|
||||
@@ -3,51 +3,19 @@ import prisma from '../lib/prisma.js';
|
||||
import * as customerService from '../services/customer.service.js';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import {
|
||||
sanitizeCustomer,
|
||||
sanitizeCustomers,
|
||||
sanitizeCustomerStrict,
|
||||
pickCustomerCreate,
|
||||
pickCustomerUpdate,
|
||||
isValidEmail,
|
||||
} from '../utils/sanitize.js';
|
||||
import {
|
||||
canAccessMeter,
|
||||
canAccessAddress,
|
||||
canAccessBankCard,
|
||||
canAccessIdentityDocument,
|
||||
canAccessCustomer,
|
||||
getPortalAllowedCustomerIds,
|
||||
} from '../utils/accessControl.js';
|
||||
|
||||
// Customer CRUD
|
||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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({
|
||||
search: search as string,
|
||||
type: type as 'PRIVATE' | 'BUSINESS',
|
||||
page: page ? parseInt(page 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
|
||||
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
|
||||
const sanitized = canSeePasswords
|
||||
? sanitizeCustomers(customers)
|
||||
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
|
||||
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
|
||||
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -56,21 +24,14 @@ 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: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.id);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const customer = await customerService.getCustomerById(customerId);
|
||||
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||
if (!customer) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
|
||||
const canSeePasswords = req.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);
|
||||
res.json({ success: true, data: customer } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
||||
}
|
||||
@@ -78,18 +39,7 @@ export async function getCustomer(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||
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;
|
||||
}
|
||||
const data = { ...req.body };
|
||||
// Convert birthDate string to Date if present
|
||||
if (data.birthDate) {
|
||||
data.birthDate = new Date(data.birthDate);
|
||||
@@ -101,14 +51,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
|
||||
customerId: customer.id,
|
||||
});
|
||||
// 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);
|
||||
res.status(201).json({ success: true, data: customer } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -120,17 +63,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.id);
|
||||
// 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 = { ...req.body };
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
@@ -155,9 +88,6 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
|
||||
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
|
||||
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
|
||||
useInformalAddress: 'Anrede per',
|
||||
autoBirthdayGreeting: 'Autom. Geburtstagsgruß',
|
||||
autoBirthdayChannel: 'Kanal für Geburtstagsgruß',
|
||||
};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Technische/interne Felder überspringen
|
||||
@@ -196,14 +126,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
res.json({ success: true, data: customer } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Update customer error:', error);
|
||||
res.status(400).json({
|
||||
@@ -234,21 +157,18 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Addresses
|
||||
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getAddresses(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const addresses = await customerService.getCustomerAddresses(customerId);
|
||||
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
|
||||
res.json({ success: true, data: addresses } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function createAddress(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const address = await customerService.createAddress(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Address',
|
||||
@@ -265,10 +185,9 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function updateAddress(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const addressId = parseInt(req.params.id);
|
||||
if (!(await canAccessAddress(req, res, addressId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -329,10 +248,9 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteAddress(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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 customerId = addr?.customerId;
|
||||
await customerService.deleteAddress(addressId);
|
||||
@@ -352,22 +270,22 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
}
|
||||
|
||||
// Bank Cards
|
||||
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getBankCards(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
|
||||
const cards = await customerService.getCustomerBankCards(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
res.json({ success: true, data: cards } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function createBankCard(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const card = await customerService.createBankCard(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'BankCard',
|
||||
@@ -384,10 +302,9 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function updateBankCard(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const cardId = parseInt(req.params.id);
|
||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -443,10 +360,9 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteBankCard(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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 customerId = card?.customerId;
|
||||
await customerService.deleteBankCard(cardId);
|
||||
@@ -466,22 +382,22 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Identity Documents
|
||||
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getDocuments(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
|
||||
const docs = await customerService.getCustomerDocuments(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
res.json({ success: true, data: docs } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function createDocument(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const doc = await customerService.createDocument(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
||||
@@ -498,10 +414,9 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function updateDocument(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const docId = parseInt(req.params.id);
|
||||
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -563,10 +478,9 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteDocument(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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 customerId = doc?.customerId;
|
||||
await customerService.deleteDocument(docId);
|
||||
@@ -586,22 +500,22 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Meters
|
||||
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getMeters(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const meters = await customerService.getCustomerMeters(customerId, showInactive);
|
||||
const meters = await customerService.getCustomerMeters(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
res.json({ success: true, data: meters } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function createMeter(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const meter = await customerService.createMeter(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Meter',
|
||||
@@ -618,10 +532,9 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function updateMeter(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.id);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const data = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
@@ -676,10 +589,9 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteMeter(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.id);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
await customerService.deleteMeter(meterId);
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'Meter',
|
||||
@@ -696,22 +608,19 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
|
||||
}
|
||||
|
||||
// Meter Readings
|
||||
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const readings = await customerService.getMeterReadings(meterId);
|
||||
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
|
||||
res.json({ success: true, data: readings } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function addMeterReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const reading = await customerService.addMeterReading(meterId, {
|
||||
readingDate: new Date(readingDate),
|
||||
value: parseFloat(value),
|
||||
@@ -744,10 +653,8 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
||||
@@ -757,7 +664,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const reading = await customerService.updateMeterReading(
|
||||
meterId,
|
||||
parseInt(req.params.meterId),
|
||||
parseInt(req.params.readingId),
|
||||
updateData as any
|
||||
);
|
||||
@@ -775,12 +682,13 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function deleteMeterReading(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const readingId = parseInt(req.params.readingId);
|
||||
await customerService.deleteMeterReading(meterId, readingId);
|
||||
await customerService.deleteMeterReading(
|
||||
parseInt(req.params.meterId),
|
||||
readingId
|
||||
);
|
||||
await logChange({
|
||||
req, action: 'DELETE', resourceType: 'MeterReading',
|
||||
resourceId: readingId.toString(),
|
||||
@@ -881,7 +789,6 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
|
||||
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const meterId = parseInt(req.params.meterId);
|
||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
||||
const readingId = parseInt(req.params.readingId);
|
||||
|
||||
const reading = await prisma.meterReading.update({
|
||||
@@ -910,11 +817,9 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
||||
|
||||
// ==================== PORTAL SETTINGS ====================
|
||||
|
||||
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const settings = await customerService.getPortalSettings(customerId);
|
||||
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
|
||||
if (!settings) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
@@ -941,27 +846,7 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis
|
||||
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
// `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;
|
||||
}
|
||||
const { portalEnabled, portalEmail } = req.body;
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.customer.findUnique({
|
||||
@@ -1021,115 +906,13 @@ 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> {
|
||||
try {
|
||||
const { password } = req.body;
|
||||
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
|
||||
const complexity = validatePasswordComplexity(password);
|
||||
if (!complexity.ok) {
|
||||
if (!password || password.length < 6) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
||||
error: 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
@@ -1150,22 +933,9 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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,
|
||||
});
|
||||
const password = await authService.getCustomerPortalPassword(parseInt(req.params.customerId));
|
||||
res.json({ success: true, data: { password } } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -1177,12 +947,10 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
||||
|
||||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||||
|
||||
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Wer kann diesen Kunden vertreten (representedBy)?
|
||||
const representedBy = await customerService.getRepresentedByList(customerId);
|
||||
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
|
||||
res.json({ success: true, data: representedBy } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -1192,10 +960,9 @@ export async function getRepresentatives(req: AuthRequest, res: Response): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function addRepresentative(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const { representativeId, notes } = req.body;
|
||||
const representative = await customerService.addRepresentative(
|
||||
customerId,
|
||||
@@ -1217,10 +984,9 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function removeRepresentative(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
await customerService.removeRepresentative(
|
||||
customerId,
|
||||
parseInt(req.params.representativeId)
|
||||
@@ -1239,13 +1005,8 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
|
||||
export async function searchForRepresentative(req: Request, res: Response): Promise<void> {
|
||||
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;
|
||||
if (!search || typeof search !== 'string' || search.length < 2) {
|
||||
res.json({ success: true, data: [] } as ApiResponse);
|
||||
@@ -1253,7 +1014,7 @@ export async function searchForRepresentative(req: AuthRequest, res: Response):
|
||||
}
|
||||
const customers = await customerService.searchCustomersForRepresentative(
|
||||
search,
|
||||
customerId,
|
||||
parseInt(req.params.customerId)
|
||||
);
|
||||
res.json({ success: true, data: customers } as ApiResponse);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,14 +4,6 @@ import { Request, Response } from 'express';
|
||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.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';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ==================== CONFIG CRUD ====================
|
||||
|
||||
@@ -120,33 +112,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
domain: req.body.domain,
|
||||
} : 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 });
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -157,188 +122,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers.
|
||||
* - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal)
|
||||
* - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert)
|
||||
*/
|
||||
export async function testMailAccess(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = req.body?.id ? parseInt(req.body.id) : undefined;
|
||||
const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined;
|
||||
const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined;
|
||||
|
||||
let emailAddress: string | undefined;
|
||||
let password: string | undefined;
|
||||
let smtpServer: string;
|
||||
let smtpPort: number;
|
||||
let imapServer: string;
|
||||
let imapPort: number;
|
||||
let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE';
|
||||
let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE';
|
||||
let allowSelfSignedCerts: boolean;
|
||||
|
||||
if (id) {
|
||||
// Gespeicherten Provider laden
|
||||
const config = await prisma.emailProviderConfig.findUnique({ where: { id } });
|
||||
if (!config) {
|
||||
res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
emailAddress = bodyEmail || config.systemEmailAddress || undefined;
|
||||
if (bodyPassword) {
|
||||
password = bodyPassword;
|
||||
} else if (config.systemEmailPasswordEncrypted) {
|
||||
try {
|
||||
password = decrypt(config.systemEmailPasswordEncrypted);
|
||||
} catch {
|
||||
password = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// IMAP/SMTP-Settings vom Provider ableiten
|
||||
const settings = await emailProviderService.getImapSmtpSettings();
|
||||
if (!settings) {
|
||||
res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
smtpServer = settings.smtpServer;
|
||||
smtpPort = settings.smtpPort;
|
||||
imapServer = settings.imapServer;
|
||||
imapPort = settings.imapPort;
|
||||
smtpEncryption = settings.smtpEncryption;
|
||||
imapEncryption = settings.imapEncryption;
|
||||
allowSelfSignedCerts = settings.allowSelfSignedCerts;
|
||||
} else if (req.body?.apiUrl) {
|
||||
// Formulardaten ohne gespeicherten Provider
|
||||
emailAddress = bodyEmail;
|
||||
password = bodyPassword;
|
||||
|
||||
try {
|
||||
const url = new URL(req.body.apiUrl);
|
||||
smtpServer = url.hostname;
|
||||
imapServer = url.hostname;
|
||||
} catch {
|
||||
smtpServer = `mail.${req.body.domain || ''}`;
|
||||
imapServer = smtpServer;
|
||||
}
|
||||
|
||||
imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
|
||||
smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
|
||||
allowSelfSignedCerts = !!req.body.allowSelfSignedCerts;
|
||||
|
||||
imapPort = imapEncryption === 'SSL' ? 993 : 143;
|
||||
smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25;
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailAddress || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'System-E-Mail-Adresse und Passwort sind erforderlich',
|
||||
} as ApiResponse);
|
||||
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
|
||||
const imapCredentials: ImapCredentials = {
|
||||
host: imapResolved.ip,
|
||||
port: imapPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: imapEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: imapResolved.servername,
|
||||
};
|
||||
|
||||
// SMTP testen
|
||||
const smtpCredentials: SmtpCredentials = {
|
||||
host: smtpResolved.ip,
|
||||
port: smtpPort,
|
||||
user: emailAddress,
|
||||
password,
|
||||
encryption: smtpEncryption,
|
||||
allowSelfSignedCerts,
|
||||
servername: smtpResolved.servername,
|
||||
};
|
||||
|
||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||
let smtpResult: { success: boolean; error?: string } = { success: false };
|
||||
|
||||
try {
|
||||
await testImapConnection(imapCredentials);
|
||||
imapResult = { success: true };
|
||||
} catch (e) {
|
||||
imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
|
||||
}
|
||||
|
||||
try {
|
||||
await testSmtpConnection(smtpCredentials);
|
||||
smtpResult = { success: true };
|
||||
} catch (e) {
|
||||
smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: imapResult.success && smtpResult.success,
|
||||
data: {
|
||||
imap: {
|
||||
...imapResult,
|
||||
server: imapServer,
|
||||
port: imapPort,
|
||||
encryption: imapEncryption,
|
||||
},
|
||||
smtp: {
|
||||
...smtpResult,
|
||||
server: smtpServer,
|
||||
port: smtpPort,
|
||||
encryption: smtpEncryption,
|
||||
},
|
||||
user: emailAddress,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('testMailAccess error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Test',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { localPart } = req.params;
|
||||
@@ -398,20 +181,3 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Provider-Einstellungen für die Frontend-UI:
|
||||
* Domain + Label für Kunden-E-Mail-Adressen.
|
||||
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
|
||||
*/
|
||||
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const settings = await emailProviderService.getProviderPublicSettings();
|
||||
res.json({ success: true, data: settings } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Einstellungen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
||||
import { createAuditLog } from '../services/audit.service.js';
|
||||
|
||||
/**
|
||||
* Factory-Defaults als ZIP exportieren (Download).
|
||||
*/
|
||||
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const buffer = await factoryDefaultsService.exportFactoryDefaults();
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `factory-defaults-${dateStr}.zip`;
|
||||
|
||||
await createAuditLog({
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email || 'unknown',
|
||||
action: 'EXPORT',
|
||||
resourceType: 'FactoryDefaults',
|
||||
resourceId: dateStr,
|
||||
resourceLabel: 'Factory-Defaults exportiert',
|
||||
endpoint: req.path,
|
||||
httpMethod: req.method,
|
||||
ipAddress: req.socket.remoteAddress || 'unknown',
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Factory-Defaults-Export:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Export',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
|
||||
*/
|
||||
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const data = await factoryDefaultsService.collectFactoryDefaults();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
counts: {
|
||||
providers: data.providers.length,
|
||||
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
|
||||
cancellationPeriods: data.cancellationPeriods.length,
|
||||
contractDurations: data.contractDurations.length,
|
||||
contractCategories: data.contractCategories.length,
|
||||
pdfTemplates: data.pdfTemplates.length,
|
||||
appSettings: data.appSettings.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Preview:', error);
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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,7 +4,6 @@ import * as gdprService from '../services/gdpr.service.js';
|
||||
import * as consentService from '../services/consent.service.js';
|
||||
import * as consentPublicService from '../services/consent-public.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 { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
@@ -13,7 +12,6 @@ import fs from 'fs';
|
||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { stripHtml } from '../utils/sanitize.js';
|
||||
|
||||
/**
|
||||
* Kundendaten exportieren (DSGVO Art. 15)
|
||||
@@ -192,12 +190,7 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
|
||||
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
|
||||
}
|
||||
|
||||
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
|
||||
const uploadsDir = path.resolve(process.cwd(), 'uploads');
|
||||
const filepath = path.resolve(uploadsDir, request.proofDocument);
|
||||
if (!filepath.startsWith(uploadsDir + path.sep)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
|
||||
}
|
||||
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
|
||||
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
@@ -231,7 +224,6 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
|
||||
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const consents = await consentService.getCustomerConsents(customerId);
|
||||
|
||||
// Labels hinzufügen
|
||||
@@ -254,7 +246,6 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const result = await consentService.hasFullConsent(customerId);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
@@ -270,14 +261,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const consentType = req.params.consentType as ConsentType;
|
||||
// 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;
|
||||
const { status, source, documentPath, version } = req.body;
|
||||
|
||||
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
|
||||
if (!(req.user as any)?.isCustomerPortal) {
|
||||
@@ -287,9 +271,17 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
|
||||
// widerrufene Vollmachten hatten vorher noch Zugriff)
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Portal: nur eigene + vertretene Kunden
|
||||
const allowed = [
|
||||
(req.user as any).customerId,
|
||||
...((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)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||
@@ -304,7 +296,9 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
|
||||
const consent = await consentService.updateConsent(customerId, consentType, {
|
||||
status,
|
||||
source: 'portal',
|
||||
source: source || 'portal',
|
||||
documentPath,
|
||||
version,
|
||||
ipAddress: req.socket.remoteAddress,
|
||||
createdBy: req.user?.email || 'unknown',
|
||||
});
|
||||
@@ -313,7 +307,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CustomerConsent',
|
||||
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
|
||||
details: { einwilligung: consentName, status, quelle: 'portal' },
|
||||
details: { einwilligung: consentName, status, quelle: source || 'portal' },
|
||||
customerId,
|
||||
});
|
||||
|
||||
@@ -800,7 +794,6 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
||||
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
||||
await authorizationService.ensureAuthorizationEntries(customerId);
|
||||
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
||||
@@ -820,15 +813,9 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
||||
const representativeId = parseInt(req.params.representativeId);
|
||||
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, {
|
||||
source: safeSource,
|
||||
notes: safeNotes as string | undefined,
|
||||
source: source || 'crm-backend',
|
||||
notes,
|
||||
});
|
||||
|
||||
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
||||
@@ -891,78 +878,6 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
||||
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 | ||||