Compare commits
63 Commits
main
..
51a25f8b0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a25f8b0b | |||
| b8a3c0d11a | |||
| 5c89847292 | |||
| 3369893f42 | |||
| 3e8ee0c1c6 | |||
| 92f4d7308d | |||
| 1290cdad10 | |||
| cfcdf088df | |||
| 620bc1bcd9 | |||
| b76ca9fd7f | |||
| 4c65353917 | |||
| ad49b92ee9 | |||
| 9d5412cef0 | |||
| 958752ecc9 | |||
| 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/uploads
|
||||||
backend/backups
|
backend/backups
|
||||||
|
|
||||||
# Daten-Verzeichnis (Bind-Mounts zur Laufzeit, nicht im Build-Context)
|
|
||||||
data/
|
|
||||||
|
|
||||||
# Plesk-Test (nicht für Container)
|
|
||||||
plesktest/
|
|
||||||
|
|
||||||
# Backup-Klone des Repos
|
|
||||||
opencrm-backup-*/
|
|
||||||
|
|
||||||
# Prisma migrations (included, but not dev db)
|
# Prisma migrations (included, but not dev db)
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
@@ -1,84 +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
|
|
||||||
|
|
||||||
# ============== 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).
|
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
||||||
|
|
||||||
**Version: 1.1.0** ([Changelog](#changelog))
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
||||||
@@ -13,9 +11,6 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||||
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
||||||
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
||||||
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT` → `ACTIVE` (mit Vertragsbeginn),
|
|
||||||
Kündigungsbestätigung-Upload setzt `ACTIVE` → `CANCELLED` (mit Datum),
|
|
||||||
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
|
|
||||||
- **Verträge**:
|
- **Verträge**:
|
||||||
- Energie (Strom, Gas)
|
- Energie (Strom, Gas)
|
||||||
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
||||||
@@ -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
|
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
||||||
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
||||||
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
||||||
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
|
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||||
Einwilligungsverwaltung, Datenexport, Löschanfragen
|
|
||||||
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
|
|
||||||
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
|
|
||||||
Sofort-E-Mail-Alerts und stündlichem Digest – siehe Einstellungen → Monitoring
|
|
||||||
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
|
|
||||||
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
|
|
||||||
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
|
||||||
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -41,70 +29,38 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Backend**: Node.js, Express 4.x, TypeScript
|
- **Backend**: Node.js, Express 4.x, TypeScript
|
||||||
- **Datenbank**: MariaDB
|
- **Datenbank**: MariaDB
|
||||||
- **ORM**: Prisma
|
- **ORM**: Prisma
|
||||||
- **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
|
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle
|
||||||
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
|
|
||||||
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
|
|
||||||
|
|
||||||
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
|
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
|
||||||
> - Express 4.x → `@types/express@^4.17.x`
|
> - Express 4.x → `@types/express@^4.17.x`
|
||||||
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
|
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
|
||||||
|
|
||||||
## Quick-Start mit Docker (empfohlen)
|
|
||||||
|
|
||||||
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd opencrm
|
|
||||||
cp .env.example .env # Werte anpassen, Secrets rotieren!
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser:
|
|
||||||
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
|
|
||||||
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
|
|
||||||
|
|
||||||
Alle persistenten Daten liegen in `./data/`:
|
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
|
||||||
|------|--------|
|
|
||||||
| `./data/db/` | MariaDB-Datafiles |
|
|
||||||
| `./data/uploads/` | User-Uploads (PDFs, Bilder) |
|
|
||||||
| `./data/factory-defaults/` | Stammdaten-Kataloge |
|
|
||||||
| `./data/backups/` | DB-Backups (`npm run db:backup`) |
|
|
||||||
|
|
||||||
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
|
|
||||||
|
|
||||||
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
|
|
||||||
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
|
|
||||||
> der initiale Admin-User `admin@admin.com` / `admin`.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Docker & Docker Compose v2
|
- Node.js 18+ (empfohlen: 20+)
|
||||||
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
|
- Docker & Docker Compose
|
||||||
|
- npm
|
||||||
|
|
||||||
## Installation für Entwicklung (ohne Container)
|
## Installation
|
||||||
|
|
||||||
### 1. Repository klonen
|
### 1. Repository klonen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd opencrm
|
cd opencrm
|
||||||
cp .env.example .env # Konfiguration anpassen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. MariaDB-Container starten
|
### 2. MariaDB-Datenbank starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d db
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Das startet nur die Datenbank (mit Daten in `./data/db/`).
|
Dies startet einen MariaDB-Container mit:
|
||||||
Konfiguration kommt aus `./.env`:
|
- **Port:** 3306
|
||||||
|
- **Datenbank:** opencrm
|
||||||
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
|
- **Root-Passwort:** rootpassword
|
||||||
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
|
- **Benutzer:** opencrm / opencrm123
|
||||||
|
|
||||||
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
|
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
|
||||||
|
|
||||||
@@ -126,14 +82,9 @@ Die `.env`-Datei sollte folgende Werte enthalten:
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
|
||||||
|
|
||||||
# JWT – Access-/Refresh-Token-Pattern (SPA-Standard)
|
# JWT
|
||||||
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
|
|
||||||
# Refresh-Token (httpOnly-Cookie, lang)
|
|
||||||
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
|
|
||||||
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
|
|
||||||
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
|
||||||
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
|
JWT_EXPIRES_IN="7d"
|
||||||
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
|
|
||||||
|
|
||||||
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
@@ -189,178 +140,6 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
|||||||
- **E-Mail:** admin@admin.com
|
- **E-Mail:** admin@admin.com
|
||||||
- **Passwort:** admin
|
- **Passwort:** admin
|
||||||
|
|
||||||
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
|
|
||||||
> ändern und Secrets rotieren – siehe [Production-Deployment](#production-deployment).
|
|
||||||
|
|
||||||
## Production-Deployment
|
|
||||||
|
|
||||||
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Pflicht-Rotation – per `openssl rand` neu generieren!
|
|
||||||
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
|
|
||||||
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
|
|
||||||
|
|
||||||
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
|
|
||||||
LISTEN_ADDR=127.0.0.1
|
|
||||||
|
|
||||||
# Bei separatem Frontend-Host: erlaubte Origins
|
|
||||||
CORS_ORIGINS=https://crm.deine-domain.de
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus:
|
|
||||||
|
|
||||||
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
|
|
||||||
die echte Client-IP gesetzt wird (nicht nur angefügt) – sonst Rate-Limit-Bypass möglich.
|
|
||||||
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
|
|
||||||
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` – wenn Frontend
|
|
||||||
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
|
|
||||||
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
|
|
||||||
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
|
|
||||||
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
|
|
||||||
auf derselben Domain via Proxy-Path.
|
|
||||||
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
|
|
||||||
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
|
||||||
durchklicken.
|
|
||||||
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
|
||||||
hinterlegen, Test-Alert senden, Digest aktivieren.
|
|
||||||
- Vollständige Hardening-Story + restliche Trade-offs:
|
|
||||||
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
|
||||||
|
|
||||||
### ⚠️ Wichtig: gzip für `/api/*` am Reverse-Proxy deaktivieren (BREACH-Schutz)
|
|
||||||
|
|
||||||
Wenn ein TLS-Reverse-Proxy (Nginx Proxy Manager, Caddy, eigener Nginx, …) HTTPS
|
|
||||||
terminiert und Antworten gzip-komprimiert, ist die **BREACH-Attacke** (CVE-2013-3587)
|
|
||||||
theoretisch möglich: aus der gzip-komprimierten Response-Größe könnten unter
|
|
||||||
ungünstigen Umständen Secrets erraten werden. Auch wenn unsere JWT-basierte SPA
|
|
||||||
das Risiko praktisch klein hält (keine reflektierten Secrets im Response-Body),
|
|
||||||
geht ein Penetration-Test mit testssl trotzdem auf „medium – Ausnutzbar: Ja".
|
|
||||||
|
|
||||||
**Lösung:** gzip-Komprimierung nur für statische Frontend-Assets erlauben, für
|
|
||||||
`/api/*` deaktivieren. Statische Bundles bleiben damit performant ausgeliefert,
|
|
||||||
JSON-API-Responses werden ohne Kompression gesendet → BREACH ist dort kein
|
|
||||||
Einfallstor mehr.
|
|
||||||
|
|
||||||
**Nginx Proxy Manager (NPM):**
|
|
||||||
1. Proxy-Hosts → den CRM-Host → **Edit**
|
|
||||||
2. Tab **Custom Locations** → **„Add location"**
|
|
||||||
3. **Define location:** `/api/`
|
|
||||||
4. **Scheme:** `http`, **Forward Hostname/IP:** wie im Haupt-Host
|
|
||||||
(z.B. `172.0.2.39`), **Forward Port:** `3010`
|
|
||||||
5. Zahnrad rechts an der Location → erweiterte Config eintragen:
|
|
||||||
```nginx
|
|
||||||
gzip off;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# Information-Disclosure-Header weg (Pentest-Hygiene):
|
|
||||||
more_clear_headers Server X-Served-By;
|
|
||||||
```
|
|
||||||
6. **Save** (Location), **Save** (Proxy-Host)
|
|
||||||
|
|
||||||
> Der `more_clear_headers`-Befehl kommt aus dem `headers-more`-Modul, das
|
|
||||||
> bei NPM standardmäßig dabei ist. Damit verschwinden die Banner
|
|
||||||
> `Server: openresty` und `x-served-by: …` aus den Responses – Pentest-
|
|
||||||
> Tools können den eingesetzten Webserver nicht mehr direkt aus dem Header
|
|
||||||
> ablesen. Wer das auch auf der Hauptlocation will, kann denselben Eintrag
|
|
||||||
> zusätzlich im **Advanced**-Tab des Proxy-Hosts setzen.
|
|
||||||
|
|
||||||
**Plain Nginx** (falls eigener Nginx statt NPM):
|
|
||||||
```nginx
|
|
||||||
location /api/ {
|
|
||||||
gzip off;
|
|
||||||
proxy_pass http://backend:3010;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
more_clear_headers Server X-Served-By; # braucht headers-more-Modul
|
|
||||||
}
|
|
||||||
# Optional global im server { … }-Block:
|
|
||||||
server_tokens off;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verifikation:**
|
|
||||||
```bash
|
|
||||||
# 1) gzip ist für /api/ deaktiviert (sollte leer sein)
|
|
||||||
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/api/health \
|
|
||||||
| grep -i content-encoding
|
|
||||||
|
|
||||||
# 2) Server-/x-served-by-Banner sind weg (sollte leer sein)
|
|
||||||
curl -sI https://kundencenter.deine-domain.de/api/health \
|
|
||||||
| grep -iE '^(server|x-served-by):'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Was mit gzip auf `/` (SPA-HTML) ist
|
|
||||||
|
|
||||||
Pentest-Tools wie `testssl` melden BREACH **trotzdem weiter** für die
|
|
||||||
Root-URL `/`, weil die SPA-`index.html` bewusst weiter gzip-komprimiert
|
|
||||||
ausgeliefert wird (Performance: 50 KB → ~10 KB). Bei OpenCRM ist der
|
|
||||||
Angriff dort nicht ausnutzbar:
|
|
||||||
|
|
||||||
- Die `/`-Response ist die statische `index.html` aus dem Vite-Build
|
|
||||||
- Sie reflektiert **keinen user-controlled Input**
|
|
||||||
- Sie enthält **keine Secrets** (JWT-Access ist im `Authorization`-Header,
|
|
||||||
Refresh-Token im httpOnly-Cookie – beides nicht im HTML-Body)
|
|
||||||
|
|
||||||
Ohne Secret-im-Body und ohne Input-Reflektion hat BREACH keinen Hebel.
|
|
||||||
|
|
||||||
##### Wer den Audit-Marker trotzdem weg haben will
|
|
||||||
|
|
||||||
Wichtig: nicht einfach eine Custom-Location für `/` mit `gzip off`
|
|
||||||
anlegen – das wäre ein **prefix-Match** und würde **alle** Pfade
|
|
||||||
außer `/api/*` betreffen, also auch `/assets/*.{js,css}`. Das JS-Bundle
|
|
||||||
käme dann unkomprimiert (~500 KB statt ~150 KB) → spürbarer
|
|
||||||
Performance-Verlust für nichts.
|
|
||||||
|
|
||||||
Sauber ist eine **exact-Match-Location** (`location = /`) – die fängt
|
|
||||||
nur die Root-URL ohne weitere Pfad-Komponente:
|
|
||||||
|
|
||||||
**Variante A** – Custom Location im NPM-UI (falls `= /` im
|
|
||||||
„Define location"-Feld akzeptiert wird):
|
|
||||||
|
|
||||||
| Feld | Wert |
|
|
||||||
|---|---|
|
|
||||||
| Define location | `= /` |
|
|
||||||
| Scheme | `http` |
|
|
||||||
| Forward Hostname/IP | wie im Haupt-Host |
|
|
||||||
| Forward Port | `3010` |
|
|
||||||
|
|
||||||
Im Zahnrad-Edit der Location:
|
|
||||||
```nginx
|
|
||||||
gzip off;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# Information-Disclosure-Header weg (Pentest-Hygiene):
|
|
||||||
more_clear_headers Server X-Served-By;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Variante B** – wenn das NPM-UI das `=` nicht akzeptiert, dieselbe
|
|
||||||
Logik im **Advanced**-Tab des Proxy-Hosts:
|
|
||||||
```nginx
|
|
||||||
location = / {
|
|
||||||
gzip off;
|
|
||||||
proxy_pass $forward_scheme://$server:$port;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
more_clear_headers Server X-Served-By;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Verifikation – `/` ohne gzip, `/assets/*` aber weiter mit:
|
|
||||||
```bash
|
|
||||||
# Root: kein Content-Encoding mehr
|
|
||||||
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/ \
|
|
||||||
| grep -i content-encoding
|
|
||||||
|
|
||||||
# /assets/<file>.js: weiterhin gzip (Performance bleibt erhalten)
|
|
||||||
JS=$(curl -s https://kundencenter.deine-domain.de/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
|
|
||||||
curl -sI -H 'Accept-Encoding: gzip' "https://kundencenter.deine-domain.de/$JS" \
|
|
||||||
| grep -i content-encoding
|
|
||||||
```
|
|
||||||
|
|
||||||
Kostet 40 KB extra pro Tab-Reload – aber dafür ist auch der letzte
|
|
||||||
BREACH-Marker weg und Pentest-Reports landen auf 0×MEDIUM.
|
|
||||||
|
|
||||||
## Developer-Tools aktivieren
|
## Developer-Tools aktivieren
|
||||||
|
|
||||||
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
||||||
@@ -1212,9 +991,8 @@ Folgende Felder werden in Audit-Logs gefiltert:
|
|||||||
## Factory-Defaults: Stammdaten-Kataloge teilen
|
## Factory-Defaults: Stammdaten-Kataloge teilen
|
||||||
|
|
||||||
Das **Factory-Defaults**-System erlaubt den Export und Import von
|
Das **Factory-Defaults**-System erlaubt den Export und Import von
|
||||||
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
|
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen
|
||||||
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
|
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups:
|
||||||
zu Datenbank-Backups:
|
|
||||||
|
|
||||||
### Abgrenzung
|
### Abgrenzung
|
||||||
|
|
||||||
@@ -1222,117 +1000,64 @@ zu Datenbank-Backups:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
|
||||||
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
|
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
|
||||||
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
|
|
||||||
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
|
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
|
||||||
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
|
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
|
||||||
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
|
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ |
|
||||||
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
|
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
|
||||||
|
|
||||||
> **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
|
> **Kurz:** Factory-Defaults = reine Kataloge, Backup = alles.
|
||||||
> Backup = die komplette Instanz.
|
|
||||||
|
|
||||||
### Drei Wege, eine ZIP zu transportieren
|
### Export (Installation A → ZIP)
|
||||||
|
|
||||||
Es gibt drei Pfade, je nachdem wo die ZIP gerade liegen soll:
|
|
||||||
|
|
||||||
| Wo | Pfad | Wann |
|
|
||||||
|---|---|---|
|
|
||||||
| **Laufende DB einer Instanz** | UI-Upload oder `./factory-import.sh` | Bestehende Live-Instanz updaten |
|
|
||||||
| **Drop-Box im Repo** (`factory-exports/`) | `./factory-export.sh` legt ab, `./factory-import.sh` liest | Transfer zwischen dev und prod via `scp` |
|
|
||||||
| **Werkseinstellung im Image** (`backend/factory-defaults/`) | `./factory-import.sh --save-as-builtin` oder manuell entpacken | Neue VMs sollen die Defaults beim allerersten Start mitbringen |
|
|
||||||
|
|
||||||
Alle drei sind unabhängig, **alle drei zusammen** decken den typischen Workflow ab.
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
**Variante A – UI:**
|
|
||||||
1. **Einstellungen** → **Factory-Defaults** öffnen
|
1. **Einstellungen** → **Factory-Defaults** öffnen
|
||||||
2. Button **„Factory-Defaults exportieren"** klicken
|
2. Übersicht prüfen (Anzahl pro Kategorie)
|
||||||
3. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
|
3. Button **„Factory-Defaults exportieren"** klicken
|
||||||
|
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
|
||||||
**Variante B – CLI (für scp-Transfers):**
|
|
||||||
```bash
|
|
||||||
./factory-export.sh # → factory-exports/factory-defaults-…zip
|
|
||||||
OPENCRM_URL=https://crm.prod.example.de \
|
|
||||||
OPENCRM_EMAIL=admin@example.de ./factory-export.sh # gegen Prod-Instanz
|
|
||||||
```
|
|
||||||
Ohne `OPENCRM_PASSWORD` wird das Passwort interaktiv abgefragt. Der Zielordner
|
|
||||||
`factory-exports/` ist gitignored – die ZIPs landen also nicht ins Repo.
|
|
||||||
|
|
||||||
**ZIP-Struktur:**
|
**ZIP-Struktur:**
|
||||||
```
|
```
|
||||||
factory-defaults-2026-05-07-1949.zip
|
factory-defaults-2026-04-23.zip
|
||||||
├── manifest.json # Version + Datum + Counts
|
├── manifest.json # Version + Datum + Counts
|
||||||
├── providers/providers.json
|
├── providers/
|
||||||
|
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
|
||||||
├── contract-meta/
|
├── contract-meta/
|
||||||
│ ├── cancellation-periods.json
|
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung)
|
||||||
│ ├── contract-durations.json
|
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung)
|
||||||
│ └── contract-categories.json
|
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...)
|
||||||
├── pdf-templates/
|
└── pdf-templates/
|
||||||
│ ├── pdf-templates.json
|
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen
|
||||||
│ └── *.pdf # Die eigentlichen PDF-Dateien
|
└── *.pdf # Die eigentlichen PDF-Dateien
|
||||||
└── app-settings/
|
|
||||||
└── app-settings.json # HTML-Templates (Whitelist-only)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import
|
Die ZIP kann an andere Installationen weitergegeben werden
|
||||||
|
(Partner, Test-System, neue Installation).
|
||||||
|
|
||||||
**Variante A – UI:**
|
### Import (ZIP → Installation B)
|
||||||
1. **Einstellungen** → **Factory-Defaults**
|
|
||||||
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
|
|
||||||
3. Erfolgs-Box zeigt Counts pro Kategorie
|
|
||||||
|
|
||||||
**Variante B – CLI:**
|
1. ZIP herunterladen bzw. erhalten
|
||||||
```bash
|
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
|
||||||
./factory-import.sh # nimmt jüngste ZIP aus factory-exports/
|
3. Im Backend-Verzeichnis ausführen:
|
||||||
./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
|
```bash
|
||||||
./factory-import.sh --save-as-builtin # zusätzlich ins Image-Default
|
npm run seed:defaults
|
||||||
./factory-import.sh --save-as-builtin ./foo.zip # entpacken (siehe unten)
|
```
|
||||||
```
|
|
||||||
|
|
||||||
Konfigurierbar per ENV: `OPENCRM_URL`, `OPENCRM_EMAIL`, `OPENCRM_PASSWORD`.
|
|
||||||
|
|
||||||
**Variante C – Container-Bare-Metal (für Migration / mehrere ZIPs zusammenführen):**
|
|
||||||
```bash
|
|
||||||
# Inhalt der ZIP nach backend/factory-defaults/ entpacken (Unterordner beibehalten)
|
|
||||||
cd backend && npm run seed:defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beispiel-Output:**
|
**Beispiel-Output:**
|
||||||
```
|
```
|
||||||
✓ Anbieter: 10
|
📦 Factory-Defaults werden eingespielt...
|
||||||
✓ Tarife: 4
|
|
||||||
✓ Kündigungsfristen: 18
|
✓ Anbieter: 7, Tarife: 12
|
||||||
✓ Laufzeiten: 18
|
✓ Kündigungsfristen: 5
|
||||||
✓ Vertragskategorien: 8
|
✓ Laufzeiten: 4
|
||||||
✓ PDF-Vorlagen: 2
|
✓ Vertragskategorien: 8
|
||||||
✓ HTML-Templates: 2
|
✓ PDF-Vorlagen: 3
|
||||||
|
|
||||||
|
✅ Factory-Defaults erfolgreich eingespielt.
|
||||||
```
|
```
|
||||||
|
|
||||||
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
|
### Mehrere ZIPs kombinieren
|
||||||
|
|
||||||
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
|
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen –
|
||||||
DB-Import** zusätzlich in `backend/factory-defaults/`. Beim nächsten
|
JSON-Dateien werden automatisch gemerged:
|
||||||
`docker-compose up --build` landen die Defaults im Image. Frisch hochgezogene
|
|
||||||
VMs bringen sie dann beim ersten Start automatisch mit (Auto-Seed-Pfad im
|
|
||||||
Container-Entrypoint).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# typischer Sync prod → dev → Image-Default
|
|
||||||
ssh prod './factory-export.sh'
|
|
||||||
scp prod:opencrm/factory-exports/factory-defaults-*.zip factory-exports/
|
|
||||||
./factory-import.sh --save-as-builtin
|
|
||||||
docker-compose up -d --build # neuer Build, neue VMs starten direkt mit Defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Inhalt von `backend/factory-defaults/` wird beim `--save-as-builtin` vorher
|
|
||||||
geleert (außer `README.md` und `.gitkeep`), damit nichts Veraltetes liegen
|
|
||||||
bleibt.
|
|
||||||
|
|
||||||
### Mehrere ZIPs kombinieren (CLI-only, Variante C)
|
|
||||||
|
|
||||||
`backend/factory-defaults/` darf mehrere `*.json` pro Unterordner haben –
|
|
||||||
`npm run seed:defaults` merged sie automatisch:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/factory-defaults/
|
backend/factory-defaults/
|
||||||
@@ -1342,121 +1067,44 @@ backend/factory-defaults/
|
|||||||
eigene.json # 5 eigene Anbieter
|
eigene.json # 5 eigene Anbieter
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
|
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per
|
||||||
nimmt nur eine ZIP entgegen – für Merges nutze `npm run seed:defaults`.
|
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch.
|
||||||
|
|
||||||
### Idempotenz
|
### Idempotenz
|
||||||
|
|
||||||
Alle Pfade nutzen Prisma `upsert`:
|
Das Script nutzt ausschließlich Prisma `upsert`:
|
||||||
- **Neue Einträge** werden angelegt
|
- **Neue Einträge** werden angelegt
|
||||||
- **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
|
- **Bestehende Einträge** (per unique Key: `name`, `code`) werden aktualisiert
|
||||||
- Nichts wird gelöscht
|
- Nichts wird gelöscht
|
||||||
|
|
||||||
Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
|
Du kannst `npm run seed:defaults` also beliebig oft ausführen, ohne Datenverlust
|
||||||
oder Duplikate.
|
oder Duplikate.
|
||||||
|
|
||||||
### PDF-Dateien
|
### PDF-Dateien beim Import
|
||||||
|
|
||||||
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
|
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach
|
||||||
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
|
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt.
|
||||||
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
|
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue
|
||||||
die neue ersetzt.
|
ersetzt.
|
||||||
|
|
||||||
### AppSettings-Whitelist
|
|
||||||
|
|
||||||
Beim Import werden nur die Keys mit AppSetting-Schreibzugriff gewährt, die auch
|
|
||||||
exportiert werden – aktuell:
|
|
||||||
|
|
||||||
- `privacyPolicyHtml`
|
|
||||||
- `imprintHtml`
|
|
||||||
- `authorizationTemplateHtml`
|
|
||||||
- `websitePrivacyPolicyHtml`
|
|
||||||
|
|
||||||
Andere Keys (SMTP, JWT, etc.) werden mit einer Warnung ignoriert. Whitelist ist
|
|
||||||
in [`backend/src/services/factoryDefaults.service.ts`](backend/src/services/factoryDefaults.service.ts)
|
|
||||||
zentral gepflegt.
|
|
||||||
|
|
||||||
### Auto-Seed beim Erst-Deploy
|
|
||||||
|
|
||||||
Bei einer **frischen** Installation (leere DB) spielt der Container-Entrypoint
|
|
||||||
nach dem Prisma-Seed automatisch das Built-in-Verzeichnis ein:
|
|
||||||
|
|
||||||
```
|
|
||||||
[entrypoint] DB ist leer (User-Count=0) – Auto-Seed wird ausgeführt
|
|
||||||
[entrypoint] Spiele eingebaute Factory-Defaults ein…
|
|
||||||
✓ Anbieter: 10, Tarife: 4
|
|
||||||
…
|
|
||||||
```
|
|
||||||
|
|
||||||
Bei bestehenden Installs passiert das **nicht** – nur frische DBs.
|
|
||||||
|
|
||||||
### Berechtigungen
|
### Berechtigungen
|
||||||
|
|
||||||
| Aktion | Berechtigung |
|
| Aktion | Berechtigung |
|
||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| Factory-Defaults Vorschau | `settings:read` |
|
| Factory-Defaults Vorschau | `settings:read` |
|
||||||
| Factory-Defaults Export (UI/CLI) | `settings:update` |
|
| Factory-Defaults Export | `settings:update` |
|
||||||
| Factory-Defaults Import (UI/CLI) | `settings:update` |
|
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
||||||
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
|
|
||||||
|
|
||||||
### Typische Einsatzzwecke
|
### Typischer Einsatzzweck
|
||||||
|
|
||||||
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
|
- **Neue Installation aufsetzen**: Eine Kollegen-ZIP importieren und sofort mit
|
||||||
(oder per `--save-as-builtin`), dann `docker-compose up --build` – die
|
gepflegtem Anbieter- und Vorlagenkatalog loslegen
|
||||||
Werkseinstellungen sind beim ersten Start automatisch drin.
|
|
||||||
- **Prod-Stand zurück nach dev synchronisieren**: `./factory-export.sh` auf prod,
|
|
||||||
`scp` ins dev, `./factory-import.sh --save-as-builtin` lokal – damit ist
|
|
||||||
sowohl die dev-DB aktuell als auch der nächste Image-Build.
|
|
||||||
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
|
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
|
||||||
(andere Ordner aus der ZIP entfernen vor dem Entpacken).
|
(die anderen Ordner einfach aus der ZIP entfernen vor dem Entpacken)
|
||||||
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
|
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
|
||||||
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
|
||||||
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
|
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### 1.1.0 (2026-05-01)
|
|
||||||
|
|
||||||
**Production-readiness** – die Version, die wirklich öffentlich gehen darf.
|
|
||||||
|
|
||||||
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
|
|
||||||
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
|
|
||||||
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
|
|
||||||
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
|
|
||||||
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
|
|
||||||
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
|
|
||||||
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
|
|
||||||
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
|
|
||||||
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
|
|
||||||
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
|
|
||||||
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
|
|
||||||
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
|
|
||||||
- 🔄 **Auto-Vertragsstatus**:
|
|
||||||
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
|
|
||||||
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
|
|
||||||
(mit Datums-Modal beim Upload)
|
|
||||||
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
|
|
||||||
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
|
|
||||||
Login transparent auf Cost 12 geupgradet.
|
|
||||||
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
|
|
||||||
über `tokenInvalidatedAt`.
|
|
||||||
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
|
|
||||||
path-to-regexp, undici, minimatch).
|
|
||||||
|
|
||||||
### 1.0.0
|
|
||||||
|
|
||||||
Erste Release-Version.
|
|
||||||
|
|
||||||
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
|
|
||||||
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
|
|
||||||
- Vertrags-Cockpit mit Rechnungsprüfung
|
|
||||||
- E-Mail-Client mit Anhang-Verwaltung
|
|
||||||
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
|
|
||||||
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
|
|
||||||
- Factory-Defaults für Stammdaten-Kataloge
|
|
||||||
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
|
|
||||||
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
|
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -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,9 +1,3 @@
|
|||||||
# Backend nutzt seit v1.1 die zentrale Root-.env im Projektverzeichnis.
|
|
||||||
# → siehe ../.env.example für alle Variablen
|
|
||||||
#
|
|
||||||
# Diese Datei bleibt als Legacy-Fallback: wenn /.env nicht existiert,
|
|
||||||
# liest das Backend backend/.env (z.B. für isolierte Backend-Tests).
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -4,11 +4,10 @@ node_modules/
|
|||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Environment – echte Secrets blocken, .env.example weiter mittracken
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Database Backups (can be large, keep folder structure)
|
# Database Backups (can be large, keep folder structure)
|
||||||
prisma/backups/*
|
prisma/backups/*
|
||||||
|
|||||||
@@ -1,71 +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
|
|
||||||
|
|
||||||
# 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,126 +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
|
|
||||||
|
|
||||||
echo "[entrypoint] Starte Backend…"
|
|
||||||
exec "$@"
|
|
||||||
@@ -18,21 +18,15 @@ backend/factory-defaults/
|
|||||||
│ ├── cancellation-periods.json # Kündigungsfristen
|
│ ├── cancellation-periods.json # Kündigungsfristen
|
||||||
│ ├── contract-durations.json # Vertragslaufzeiten
|
│ ├── contract-durations.json # Vertragslaufzeiten
|
||||||
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
||||||
├── pdf-templates/
|
└── pdf-templates/
|
||||||
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
|
├── pdf-templates.json # Metadaten + Feldzuordnungen
|
||||||
│ └── *.pdf # PDF-Vorlagen-Dateien
|
└── *.pdf # PDF-Vorlagen-Dateien
|
||||||
└── app-settings/
|
|
||||||
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
|
|
||||||
# Vollmacht / Website-Datenschutz
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
||||||
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
|
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten
|
||||||
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
||||||
|
|
||||||
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
|
|
||||||
Standardpflichten) – andere Keys werden beim Import ignoriert.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Export (aus einer bestehenden Installation)
|
## Export (aus einer bestehenden Installation)
|
||||||
@@ -52,8 +46,7 @@ factory-defaults-2026-04-23.zip
|
|||||||
├── contract-meta/contract-durations.json
|
├── contract-meta/contract-durations.json
|
||||||
├── contract-meta/contract-categories.json
|
├── contract-meta/contract-categories.json
|
||||||
├── pdf-templates/pdf-templates.json
|
├── pdf-templates/pdf-templates.json
|
||||||
├── pdf-templates/*.pdf
|
└── pdf-templates/*.pdf
|
||||||
└── app-settings/app-settings.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
||||||
@@ -63,15 +56,7 @@ neue Installationen oder Partner-Setups.
|
|||||||
|
|
||||||
## Import (in eine andere Installation)
|
## Import (in eine andere Installation)
|
||||||
|
|
||||||
### Variante A: Über die UI (empfohlen)
|
### Schritt-für-Schritt
|
||||||
|
|
||||||
1. Im Ziel-CRM als Admin einloggen
|
|
||||||
2. **Einstellungen → Factory-Defaults**
|
|
||||||
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
|
|
||||||
4. Die exportierte ZIP wählen – der Import läuft direkt
|
|
||||||
5. Erfolgsmeldung zeigt Counts pro Kategorie an
|
|
||||||
|
|
||||||
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
|
|
||||||
|
|
||||||
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
||||||
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
||||||
@@ -249,24 +234,6 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|
|||||||
**Unique Key:** `name`
|
**Unique Key:** `name`
|
||||||
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
||||||
|
|
||||||
### `app-settings/app-settings.json`
|
|
||||||
|
|
||||||
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv – andere Keys
|
|
||||||
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
|
|
||||||
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
|
|
||||||
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
|
|
||||||
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Unique Key:** `key`
|
|
||||||
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
|
|
||||||
`websitePrivacyPolicyHtml`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Berechtigungen
|
## Berechtigungen
|
||||||
@@ -275,7 +242,6 @@ werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secr
|
|||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| Factory-Defaults Vorschau | `settings:read` |
|
| Factory-Defaults Vorschau | `settings:read` |
|
||||||
| Factory-Defaults Export (UI) | `settings:update` |
|
| Factory-Defaults Export (UI) | `settings:update` |
|
||||||
| Factory-Defaults Import (UI) | `settings:update` |
|
|
||||||
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+101
-248
@@ -1,35 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"dotenv-expand": "^13.0.0",
|
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"undici": "^6.23.0"
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -42,10 +35,10 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -56,6 +49,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -71,6 +65,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -86,6 +81,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -101,6 +97,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -116,6 +113,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -131,6 +129,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -146,6 +145,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -161,6 +161,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -176,6 +177,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -191,6 +193,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -206,6 +209,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -221,6 +225,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -236,6 +241,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -251,6 +257,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -266,6 +273,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -281,6 +289,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -296,6 +305,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -311,6 +321,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -326,6 +337,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -341,6 +353,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -356,6 +369,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -371,6 +385,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -386,6 +401,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -401,6 +417,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -416,6 +433,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -431,6 +449,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -488,8 +507,7 @@
|
|||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
@@ -610,6 +628,7 @@
|
|||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -619,19 +638,11 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/cookie-parser": {
|
|
||||||
"version": "1.4.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
|
||||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/express": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/cors": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -645,6 +656,7 @@
|
|||||||
"version": "4.17.25",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^4.17.33",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
@@ -656,6 +668,7 @@
|
|||||||
"version": "4.19.8",
|
"version": "4.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"@types/qs": "*",
|
"@types/qs": "*",
|
||||||
@@ -666,7 +679,8 @@
|
|||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/jsonwebtoken": {
|
"node_modules/@types/jsonwebtoken": {
|
||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
@@ -703,7 +717,8 @@
|
|||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@@ -724,17 +739,11 @@
|
|||||||
"version": "22.19.7",
|
"version": "22.19.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "7.0.9",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
@@ -756,12 +765,14 @@
|
|||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/readdir-glob": {
|
"node_modules/@types/readdir-glob": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
@@ -776,6 +787,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -784,6 +796,7 @@
|
|||||||
"version": "1.15.10",
|
"version": "1.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -794,6 +807,7 @@
|
|||||||
"version": "0.17.6",
|
"version": "0.17.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -961,7 +975,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -1045,10 +1058,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1251,25 +1263,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-parser": {
|
|
||||||
"version": "1.4.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
|
||||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "0.7.2",
|
|
||||||
"cookie-signature": "1.0.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -1456,33 +1449,6 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv-expand": {
|
|
||||||
"version": "13.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz",
|
|
||||||
"integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^17.4.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv-expand/node_modules/dotenv": {
|
|
||||||
"version": "17.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
|
||||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1577,6 +1543,7 @@
|
|||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
@@ -1695,24 +1662,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||||
@@ -1803,6 +1752,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1859,6 +1809,7 @@
|
|||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -1931,15 +1882,6 @@
|
|||||||
"he": "bin/he"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
@@ -2023,31 +1965,19 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.3.3",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
|
||||||
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
|
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.9",
|
"@zone-eu/mailsplit": "5.4.8",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.3.8",
|
"libmime": "5.3.7",
|
||||||
"libqp": "2.1.1",
|
"libqp": "2.1.1",
|
||||||
"nodemailer": "8.0.7",
|
"nodemailer": "7.0.13",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.0",
|
||||||
"socks": "2.8.8"
|
"socks": "2.8.7"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
|
|
||||||
"version": "5.4.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
|
|
||||||
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
|
|
||||||
"license": "(MIT OR EUPL-1.1+)",
|
|
||||||
"dependencies": {
|
|
||||||
"libbase64": "1.3.0",
|
|
||||||
"libmime": "5.3.8",
|
|
||||||
"libqp": "2.1.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow/node_modules/iconv-lite": {
|
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||||
@@ -2065,27 +1995,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow/node_modules/libmime": {
|
|
||||||
"version": "5.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
|
||||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encoding-japanese": "2.2.0",
|
|
||||||
"iconv-lite": "0.7.2",
|
|
||||||
"libbase64": "1.3.0",
|
|
||||||
"libqp": "2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imapflow/node_modules/nodemailer": {
|
|
||||||
"version": "8.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
|
||||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -2278,10 +2187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
@@ -2324,19 +2232,18 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.9.8",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
||||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.8",
|
"@zone-eu/mailsplit": "5.4.8",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libmime": "5.3.8",
|
"libmime": "5.3.7",
|
||||||
"linkify-it": "5.0.0",
|
"linkify-it": "5.0.0",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "7.0.13",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.261.0"
|
"tlds": "1.261.0"
|
||||||
}
|
}
|
||||||
@@ -2356,27 +2263,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser/node_modules/libmime": {
|
|
||||||
"version": "5.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
|
||||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encoding-japanese": "2.2.0",
|
|
||||||
"iconv-lite": "0.7.2",
|
|
||||||
"libbase64": "1.3.0",
|
|
||||||
"libqp": "2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mailparser/node_modules/nodemailer": {
|
|
||||||
"version": "8.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
|
||||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2440,12 +2326,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.9",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.2"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -2512,15 +2397,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
@@ -2560,7 +2436,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -2630,10 +2505,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/pdf-lib": {
|
"node_modules/pdf-lib": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
@@ -2680,10 +2554,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinojs/redact": "^0.4.0",
|
"@pinojs/redact": "^0.4.0",
|
||||||
"atomic-sleep": "^1.0.0",
|
"atomic-sleep": "^1.0.0",
|
||||||
@@ -2705,7 +2578,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"split2": "^4.0.0"
|
"split2": "^4.0.0"
|
||||||
}
|
}
|
||||||
@@ -2713,8 +2585,7 @@
|
|||||||
"node_modules/pino-std-serializers": {
|
"node_modules/pino-std-serializers": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/png-js": {
|
"node_modules/png-js": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2766,8 +2637,7 @@
|
|||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/fastify"
|
"url": "https://opencollective.com/fastify"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
@@ -2790,10 +2660,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -2807,8 +2676,7 @@
|
|||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -2860,10 +2728,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
"version": "5.1.9",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -2875,7 +2742,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.13.0"
|
"node": ">= 12.13.0"
|
||||||
}
|
}
|
||||||
@@ -2884,6 +2750,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
@@ -2916,7 +2783,6 @@
|
|||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -3097,19 +2963,17 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0",
|
"node": ">= 6.0.0",
|
||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.8",
|
"version": "2.8.7",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||||
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
|
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.1.1",
|
"ip-address": "^10.0.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3117,20 +2981,10 @@
|
|||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks/node_modules/ip-address": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"atomic-sleep": "^1.0.0"
|
"atomic-sleep": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -3139,7 +2993,6 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
@@ -3293,7 +3146,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"real-require": "^0.2.0"
|
"real-require": "^0.2.0"
|
||||||
},
|
},
|
||||||
@@ -3331,6 +3183,7 @@
|
|||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -3381,10 +3234,9 @@
|
|||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.25.0",
|
"version": "6.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
@@ -3392,7 +3244,8 @@
|
|||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/unicode-properties": {
|
"node_modules/unicode-properties": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
|
|||||||
+3
-11
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"description": "OpenCRM Backend API",
|
"description": "OpenCRM Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "npx tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"schema:sync": "prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)",
|
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:backup": "tsx prisma/backup-data.ts",
|
"db:backup": "tsx prisma/backup-data.ts",
|
||||||
@@ -21,27 +20,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"dotenv-expand": "^13.0.0",
|
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"undici": "^6.23.0"
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -54,10 +46,10 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Customer` ADD COLUMN `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (i.e. Git)
|
||||||
provider = "mysql"
|
provider = "mysql"
|
||||||
@@ -78,10 +78,6 @@ model User {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
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)
|
// Messaging-Kanäle (für Datenschutz-Link-Versand)
|
||||||
whatsappNumber String?
|
whatsappNumber String?
|
||||||
telegramUsername String?
|
telegramUsername String?
|
||||||
@@ -167,16 +163,6 @@ model Customer {
|
|||||||
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
||||||
portalLastLogin DateTime? // Letzte Anmeldung
|
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)
|
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
||||||
lastBirthdayGreetingYear Int?
|
lastBirthdayGreetingYear Int?
|
||||||
|
|
||||||
@@ -1117,53 +1103,3 @@ model AuditRetentionPolicy {
|
|||||||
|
|
||||||
@@unique([resourceType, sensitivity])
|
@@unique([resourceType, sensitivity])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SECURITY MONITORING ====================
|
|
||||||
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
|
|
||||||
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
|
|
||||||
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
|
|
||||||
// effizient querybar). Threshold-Detection läuft per Cron.
|
|
||||||
|
|
||||||
enum SecurityEventType {
|
|
||||||
LOGIN_FAILED // falsches Passwort / unbekannter User
|
|
||||||
LOGIN_SUCCESS // erfolgreicher Login (informativ)
|
|
||||||
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
|
|
||||||
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
|
|
||||||
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
|
|
||||||
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
|
|
||||||
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
|
|
||||||
LOGOUT // expliziter Logout
|
|
||||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
|
||||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
|
||||||
SUSPICIOUS // generischer Catch-All
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SecuritySeverity {
|
|
||||||
INFO // Login-Success, Logout
|
|
||||||
LOW // Einzelner failed Login, einzelner 403
|
|
||||||
MEDIUM // Rate-Limit-Hit, mehrere 403er
|
|
||||||
HIGH // SSRF-Block, JWT-Manipulation
|
|
||||||
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
|
|
||||||
}
|
|
||||||
|
|
||||||
model SecurityEvent {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
type SecurityEventType
|
|
||||||
severity SecuritySeverity
|
|
||||||
message String @db.Text
|
|
||||||
ipAddress String?
|
|
||||||
userId Int? // Mitarbeiter (falls eingeloggt)
|
|
||||||
customerId Int? // Portal-Kunde (falls eingeloggt)
|
|
||||||
userEmail String? // beste Schätzung – auch bei nicht eingeloggt
|
|
||||||
endpoint String? // betroffener Endpoint
|
|
||||||
details Json? // strukturierte Zusatzinfo
|
|
||||||
alerted Boolean @default(false) // schon per Email versendet?
|
|
||||||
alertedAt DateTime?
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([type, createdAt])
|
|
||||||
@@index([severity, createdAt])
|
|
||||||
@@index([ipAddress, createdAt])
|
|
||||||
@@index([alerted, severity])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// ROOT kann via FACTORY_DEFAULTS_DIR überschrieben werden (Container-Bootstrap
|
const ROOT = path.join(process.cwd(), 'factory-defaults');
|
||||||
// mit eingebauten Defaults aus dem Image).
|
|
||||||
const ROOT = process.env.FACTORY_DEFAULTS_DIR
|
|
||||||
? path.resolve(process.env.FACTORY_DEFAULTS_DIR)
|
|
||||||
: path.join(process.cwd(), 'factory-defaults');
|
|
||||||
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
|
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
|
||||||
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
|
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
|
||||||
|
|
||||||
@@ -65,19 +61,6 @@ interface PdfTemplateDef {
|
|||||||
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSettingDef {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whitelist – muss synchron zu factoryDefaults.service.ts sein.
|
|
||||||
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
|
|
||||||
'privacyPolicyHtml',
|
|
||||||
'authorizationTemplateHtml',
|
|
||||||
'imprintHtml',
|
|
||||||
'websitePrivacyPolicyHtml',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
||||||
*/
|
*/
|
||||||
@@ -316,31 +299,6 @@ async function seedPdfTemplates() {
|
|||||||
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedAppSettings() {
|
|
||||||
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
|
|
||||||
if (items.length === 0) {
|
|
||||||
console.log(' app-settings – keine Einträge');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let count = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
for (const s of items) {
|
|
||||||
if (!s.key || typeof s.value !== 'string') continue;
|
|
||||||
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
|
|
||||||
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist – übersprungen`);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await prisma.appSetting.upsert({
|
|
||||||
where: { key: s.key },
|
|
||||||
update: { value: s.value },
|
|
||||||
create: { key: s.key, value: s.value },
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
||||||
|
|
||||||
@@ -355,7 +313,6 @@ async function main() {
|
|||||||
await seedContractDurations();
|
await seedContractDurations();
|
||||||
await seedContractCategories();
|
await seedContractCategories();
|
||||||
await seedPdfTemplates();
|
await seedPdfTemplates();
|
||||||
await seedAppSettings();
|
|
||||||
|
|
||||||
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,12 @@
|
|||||||
import { Request, Response, CookieOptions } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
|
||||||
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
|
|
||||||
|
|
||||||
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
|
|
||||||
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
|
|
||||||
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
|
|
||||||
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
|
|
||||||
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
|
|
||||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
|
||||||
function getRefreshCookieOptions(): CookieOptions {
|
|
||||||
return {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.HTTPS_ENABLED === 'true',
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function setRefreshCookie(res: Response, token: string): void {
|
|
||||||
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
|
|
||||||
}
|
|
||||||
function clearRefreshCookie(res: Response): void {
|
|
||||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
|
|
||||||
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
|
|
||||||
// wird als generisches "Anmeldung fehlgeschlagen" maskiert – die Original-
|
|
||||||
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
|
|
||||||
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
|
|
||||||
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
|
|
||||||
const SAFE_LOGIN_ERRORS = new Set([
|
|
||||||
'Ungültige Anmeldedaten',
|
|
||||||
'E-Mail und Passwort erforderlich',
|
|
||||||
]);
|
|
||||||
function safeLoginError(err: unknown): string {
|
|
||||||
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
|
|
||||||
return err.message;
|
|
||||||
}
|
|
||||||
if (err instanceof Error) {
|
|
||||||
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
|
|
||||||
}
|
|
||||||
return 'Anmeldung fehlgeschlagen';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
export async function login(req: Request, res: Response): Promise<void> {
|
||||||
const { email, password } = req.body || {};
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
try {
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -61,43 +16,20 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.login(email, password);
|
const result = await authService.login(email, password);
|
||||||
// Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
|
|
||||||
setRefreshCookie(res, result.refreshToken);
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'LOGIN_SUCCESS',
|
|
||||||
severity: 'INFO',
|
|
||||||
message: `Mitarbeiter-Login: ${email}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userId: result.user.id,
|
|
||||||
userEmail: email,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { token: result.accessToken, user: result.user },
|
|
||||||
} as ApiResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'LOGIN_FAILED',
|
|
||||||
severity: 'LOW',
|
|
||||||
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userEmail: email,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
});
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: safeLoginError(error),
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kundenportal-Login
|
// Kundenportal-Login
|
||||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||||
const { email, password } = req.body || {};
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
try {
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -107,32 +39,11 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.customerLogin(email, password);
|
const result = await authService.customerLogin(email, password);
|
||||||
setRefreshCookie(res, result.refreshToken);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'LOGIN_SUCCESS',
|
|
||||||
severity: 'INFO',
|
|
||||||
message: `Portal-Login: ${email}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
customerId: result.user.customerId,
|
|
||||||
userEmail: email,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { token: result.accessToken, user: result.user },
|
|
||||||
} as ApiResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'LOGIN_FAILED',
|
|
||||||
severity: 'LOW',
|
|
||||||
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userEmail: email,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
});
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: safeLoginError(error),
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,183 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const complexity = validatePasswordComplexity(password);
|
|
||||||
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> {
|
export async function register(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||||
@@ -377,15 +111,6 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complexity = validatePasswordComplexity(password);
|
|
||||||
if (!complexity.ok) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await authService.createUser({
|
const user = await authService.createUser({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -405,86 +130,3 @@ export async function register(req: Request, res: Response): Promise<void> {
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
|
|
||||||
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
|
|
||||||
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
|
|
||||||
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
|
|
||||||
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload: any = {
|
|
||||||
email: req.user.email,
|
|
||||||
permissions: req.user.permissions,
|
|
||||||
isCustomerPortal: !!req.user.isCustomerPortal,
|
|
||||||
};
|
|
||||||
if (req.user.userId) payload.userId = req.user.userId;
|
|
||||||
if (req.user.customerId) payload.customerId = req.user.customerId;
|
|
||||||
if ((req.user as any).representedCustomerIds) {
|
|
||||||
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
|
|
||||||
}
|
|
||||||
const token = authService.signDownloadToken(payload);
|
|
||||||
res.json({ success: true, data: { token } } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Fehler beim Erstellen des Download-Tokens',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
|
|
||||||
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
|
|
||||||
// loggt aus und schickt zurück zum Login.
|
|
||||||
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Nur für Kundenportal-Login',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
|
|
||||||
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
|
|
||||||
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) – KRITISCH.
|
|
||||||
const customer = await prisma.customer.findUnique({
|
|
||||||
where: { id: req.user.customerId },
|
|
||||||
select: { portalPasswordMustChange: true },
|
|
||||||
});
|
|
||||||
if (!customer?.portalPasswordMustChange) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Nicht erlaubt',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { newPassword } = req.body || {};
|
|
||||||
if (!newPassword || typeof newPassword !== 'string') {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Neues Passwort erforderlich',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const complexity = validatePasswordComplexity(newPassword);
|
|
||||||
if (!complexity.ok) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
|
|
||||||
clearRefreshCookie(res);
|
|
||||||
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as backupService from '../services/backup.service.js';
|
import * as backupService from '../services/backup.service.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* 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';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +45,8 @@ export async function restoreBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.restoreBackup(name);
|
const result = await backupService.restoreBackup(name);
|
||||||
@@ -88,8 +79,8 @@ export async function deleteBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.deleteBackup(name);
|
const result = await backupService.deleteBackup(name);
|
||||||
@@ -116,8 +107,8 @@ export async function downloadBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.createBackupZip(name);
|
const result = await backupService.createBackupZip(name);
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ import { createAuditLog } from '../services/audit.service.js';
|
|||||||
*/
|
*/
|
||||||
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
|
|
||||||
// Telefon und Geburtsdatum ALLER Kunden – ausschließlich Mitarbeiter-UI.
|
|
||||||
// Pentest Runde 6 (2026-05-16) – HOCH.
|
|
||||||
if (req.user?.isCustomerPortal) {
|
|
||||||
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
|
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
|
||||||
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
|
||||||
|
|
||||||
|
|||||||
@@ -8,42 +8,20 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
|||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
|
||||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||||
import { generateEmailPdf } from '../services/pdfService.js';
|
import { generateEmailPdf } from '../services/pdfService.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
|
||||||
import { DocumentType } from '@prisma/client';
|
import { DocumentType } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import {
|
|
||||||
canAccessCustomer,
|
|
||||||
canAccessContract,
|
|
||||||
canAccessCachedEmail,
|
|
||||||
canAccessStressfreiEmail,
|
|
||||||
} from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
// ==================== E-MAIL LIST ====================
|
// ==================== E-MAIL LIST ====================
|
||||||
|
|
||||||
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
|
|
||||||
function parseBoolParam(v: unknown): boolean | undefined {
|
|
||||||
if (v === 'true') return true;
|
|
||||||
if (v === 'false') return false;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDateParam(v: unknown): Date | undefined {
|
|
||||||
if (typeof v !== 'string' || !v.trim()) return undefined;
|
|
||||||
const d = new Date(v);
|
|
||||||
return isNaN(d.getTime()) ? undefined : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
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,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
includeBody: false,
|
includeBody: false,
|
||||||
search: typeof req.query.search === 'string' ? req.query.search : undefined,
|
|
||||||
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
|
|
||||||
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
|
|
||||||
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
|
|
||||||
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
|
|
||||||
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
|
|
||||||
hasAttachments: parseBoolParam(req.query.hasAttachments),
|
|
||||||
isRead: parseBoolParam(req.query.isRead),
|
|
||||||
isStarred: parseBoolParam(req.query.isStarred),
|
|
||||||
receivedFrom: parseDateParam(req.query.receivedFrom),
|
|
||||||
receivedTo: parseDateParam(req.query.receivedTo),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
@@ -80,10 +47,9 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mails für einen Vertrag abrufen
|
// 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 {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
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 folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
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 ====================
|
// ==================== SINGLE EMAIL ====================
|
||||||
|
|
||||||
// Einzelne E-Mail abrufen (mit Body)
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(id);
|
const email = await cachedEmailService.getCachedEmailById(id);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -138,10 +102,9 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail als gelesen/ungelesen markieren
|
// E-Mail als gelesen/ungelesen markieren
|
||||||
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
|
export async function markAsRead(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
const { isRead } = req.body;
|
const { isRead } = req.body;
|
||||||
|
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
@@ -161,10 +124,9 @@ export async function markAsRead(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail Stern umschalten
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||||||
|
|
||||||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||||||
@@ -180,12 +142,10 @@ export async function toggleStar(req: AuthRequest, res: Response): Promise<void>
|
|||||||
// ==================== CONTRACT ASSIGNMENT ====================
|
// ==================== CONTRACT ASSIGNMENT ====================
|
||||||
|
|
||||||
// E-Mail einem Vertrag zuordnen
|
// 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 {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const { contractId } = req.body;
|
const { contractId } = req.body;
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||||||
|
|
||||||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||||||
@@ -201,10 +161,9 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vertragszuordnung aufheben
|
// Vertragszuordnung aufheben
|
||||||
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||||||
|
|
||||||
@@ -219,10 +178,9 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für ein Konto
|
// 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 {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -237,10 +195,9 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
// 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 {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||||||
|
|
||||||
@@ -257,10 +214,9 @@ export async function getContractFolderCounts(req: AuthRequest, res: Response):
|
|||||||
// ==================== SYNC & SEND ====================
|
// ==================== SYNC & SEND ====================
|
||||||
|
|
||||||
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
||||||
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function syncAccount(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
|
||||||
const fullSync = req.query.full === 'true';
|
const fullSync = req.query.full === 'true';
|
||||||
|
|
||||||
// Synchronisiert sowohl INBOX als auch SENT
|
// Synchronisiert sowohl INBOX als auch SENT
|
||||||
@@ -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
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
|
||||||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
||||||
|
|
||||||
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
|
|
||||||
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -459,10 +396,9 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
// ==================== ATTACHMENTS ====================
|
// ==================== ATTACHMENTS ====================
|
||||||
|
|
||||||
// Anhang-Liste einer E-Mail abrufen
|
// 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 {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
@@ -493,14 +429,11 @@ export async function getAttachments(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Einzelnen Anhang herunterladen
|
// Einzelnen Anhang herunterladen
|
||||||
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
|
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
|
|
||||||
// Portal-Isolation: nur eigene/vertretene Emails
|
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -567,26 +500,10 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
// Datei senden - inline (öffnen) oder attachment (download)
|
||||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
res.setHeader('Content-Type', attachment.contentType);
|
||||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
||||||
const INLINE_SAFE_TYPES = new Set([
|
|
||||||
'application/pdf',
|
|
||||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
|
||||||
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
|
||||||
'text/plain',
|
|
||||||
]);
|
|
||||||
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
|
||||||
// SVG kann Skripte enthalten → niemals inline
|
|
||||||
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
|
||||||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
|
||||||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
|
||||||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
|
||||||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
|
||||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
|
||||||
res.setHeader('Content-Length', attachment.size);
|
res.setHeader('Content-Length', attachment.size);
|
||||||
res.send(attachment.content);
|
res.send(attachment.content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -616,10 +533,9 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
// ==================== MAILBOX ACCOUNTS ====================
|
// ==================== MAILBOX ACCOUNTS ====================
|
||||||
|
|
||||||
// Mailbox-Konten eines Kunden abrufen
|
// Mailbox-Konten eines Kunden abrufen
|
||||||
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
|
|
||||||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||||||
|
|
||||||
@@ -634,10 +550,9 @@ export async function getMailboxAccounts(req: AuthRequest, res: Response): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox nachträglich aktivieren
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const result = await stressfreiEmailService.enableMailbox(id);
|
const result = await stressfreiEmailService.enableMailbox(id);
|
||||||
|
|
||||||
@@ -660,10 +575,9 @@ export async function enableMailbox(req: AuthRequest, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox-Status mit Provider synchronisieren
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
||||||
|
|
||||||
@@ -692,10 +606,9 @@ export async function syncMailboxStatus(req: AuthRequest, res: Response): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Thread abrufen
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const thread = await cachedEmailService.getEmailThread(id);
|
const thread = await cachedEmailService.getEmailThread(id);
|
||||||
|
|
||||||
@@ -710,13 +623,9 @@ export async function getThread(req: AuthRequest, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
// Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit
|
|
||||||
// bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials
|
|
||||||
// eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX.
|
|
||||||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
||||||
@@ -751,15 +660,6 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
// IMAP/SMTP-Einstellungen laden
|
// IMAP/SMTP-Einstellungen laden
|
||||||
const settings = await getImapSmtpSettings();
|
const settings = await getImapSmtpSettings();
|
||||||
|
|
||||||
// Klartext-Mailbox-Passwort-Read auditieren (CRITICAL)
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'READ',
|
|
||||||
resourceType: 'MailboxCredentials',
|
|
||||||
resourceId: id.toString(),
|
|
||||||
label: `Klartext-Mailbox-Zugangsdaten von ${stressfreiEmail.email} entschlüsselt`,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -787,7 +687,7 @@ export async function getMailboxCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ungelesene E-Mails zählen
|
// 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 {
|
try {
|
||||||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||||||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||||||
@@ -795,10 +695,8 @@ export async function getUnreadCount(req: AuthRequest, res: Response): Promise<v
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||||||
} else if (contractId) {
|
} else if (contractId) {
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,10 +711,9 @@ export async function getUnreadCount(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail in Papierkorb verschieben (nur Admin)
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
// Prüfen ob E-Mail existiert
|
// Prüfen ob E-Mail existiert
|
||||||
const email = await cachedEmailService.getCachedEmailById(id);
|
const email = await cachedEmailService.getCachedEmailById(id);
|
||||||
@@ -851,10 +748,9 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
// ==================== TRASH OPERATIONS ====================
|
// ==================== TRASH OPERATIONS ====================
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
|
|
||||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||||||
|
|
||||||
@@ -869,10 +765,9 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-Anzahl für einen Kunden
|
// 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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
|
|
||||||
const count = await cachedEmailService.getTrashCount(customerId);
|
const count = await cachedEmailService.getTrashCount(customerId);
|
||||||
|
|
||||||
@@ -887,10 +782,9 @@ export async function getTrashCount(req: AuthRequest, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail aus Papierkorb wiederherstellen
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||||||
|
|
||||||
@@ -913,10 +807,9 @@ export async function restoreEmail(req: AuthRequest, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail endgültig löschen (aus Papierkorb)
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||||||
|
|
||||||
@@ -941,10 +834,9 @@ export async function permanentDeleteEmail(req: AuthRequest, res: Response): Pro
|
|||||||
// ==================== ATTACHMENT TARGETS ====================
|
// ==================== ATTACHMENT TARGETS ====================
|
||||||
|
|
||||||
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
||||||
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
|
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
// E-Mail mit StressfreiEmail laden
|
// E-Mail mit StressfreiEmail laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
@@ -1124,10 +1016,9 @@ export async function getAttachmentTargets(req: AuthRequest, res: Response): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anhang in ein Dokumentenfeld speichern
|
// 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 {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { entityType, entityId, targetKey } = req.body;
|
const { entityType, entityId, targetKey } = req.body;
|
||||||
|
|
||||||
@@ -1412,10 +1303,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
// ==================== SAVE EMAIL AS PDF ====================
|
// ==================== SAVE EMAIL AS PDF ====================
|
||||||
|
|
||||||
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
||||||
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
|
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const { entityType, entityId, targetKey } = req.body;
|
const { entityType, entityId, targetKey } = req.body;
|
||||||
|
|
||||||
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
|
||||||
@@ -1660,10 +1550,9 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
|
|||||||
// ==================== SAVE EMAIL AS INVOICE ====================
|
// ==================== SAVE EMAIL AS INVOICE ====================
|
||||||
|
|
||||||
// E-Mail als PDF exportieren und als Rechnung speichern
|
// E-Mail als PDF exportieren und als Rechnung speichern
|
||||||
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||||||
@@ -1787,10 +1676,9 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
|||||||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||||
|
|
||||||
// E-Mail-Anhang als Rechnung speichern
|
// E-Mail-Anhang als Rechnung speichern
|
||||||
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
|
||||||
@@ -1951,10 +1839,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||||||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||||||
*/
|
*/
|
||||||
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { documentType, notes } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
@@ -1990,9 +1877,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
|
|
||||||
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
|
||||||
|
|
||||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||||
if (email.folder === 'SENT' && email.uid === 0) {
|
if (email.folder === 'SENT' && email.uid === 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -2069,10 +1953,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
|
|||||||
@@ -138,13 +138,7 @@ export async function grantAllConsents(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
|
res.json({ success: true, data: results });
|
||||||
// keine internen IDs – das war früher der volle CustomerConsent-Record und
|
|
||||||
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { granted: results.length },
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
console.error('Fehler beim Erteilen der Einwilligungen:', error);
|
||||||
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
|||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
|
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
|
||||||
|
|
||||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -47,15 +44,9 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
|||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
});
|
});
|
||||||
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
|
||||||
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
|
||||||
const isPortal = !!req.user?.isCustomerPortal;
|
|
||||||
const data = isPortal
|
|
||||||
? sanitizeContractsStrict(result.contracts as any[])
|
|
||||||
: sanitizeContracts(result.contracts as any[]);
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data: result.contracts,
|
||||||
pagination: result.pagination,
|
pagination: result.pagination,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,11 +87,7 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPortal = !!req.user?.isCustomerPortal;
|
res.json({ success: true, data: contract } as ApiResponse);
|
||||||
const data = isPortal
|
|
||||||
? sanitizeContractStrict(contract as any)
|
|
||||||
: sanitizeContract(contract as any);
|
|
||||||
res.json({ success: true, data } as ApiResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -222,7 +209,6 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
|||||||
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const previousContractId = parseInt(req.params.id);
|
const previousContractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, previousContractId))) return;
|
|
||||||
|
|
||||||
// Vorgängervertrag laden für Vertragsnummer
|
// Vorgängervertrag laden für Vertragsnummer
|
||||||
const previousContract = await prisma.contract.findUnique({
|
const previousContract = await prisma.contract.findUnique({
|
||||||
@@ -268,65 +254,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getContractPassword(req: Request, res: Response): Promise<void> {
|
||||||
* VVL = Vertragsverlängerung beim selben Anbieter.
|
|
||||||
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
|
|
||||||
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
|
|
||||||
*/
|
|
||||||
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const previousContractId = parseInt(req.params.id);
|
const password = await contractService.getContractPassword(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,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: contract } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const contractId = parseInt(req.params.id);
|
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const password = await contractService.getContractPassword(contractId);
|
|
||||||
if (password === null) {
|
if (password === null) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -334,14 +264,6 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Klartext-Passwort-Read auditieren (CRITICAL)
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'READ',
|
|
||||||
resourceType: 'ContractPassword',
|
|
||||||
resourceId: contractId.toString(),
|
|
||||||
label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -351,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 {
|
try {
|
||||||
const simCardId = parseInt(req.params.simCardId);
|
const credentials = await contractService.getSimCardCredentials(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`,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -383,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 {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const credentials = await contractService.getInternetCredentials(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`,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -406,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 {
|
try {
|
||||||
const phoneNumberId = parseInt(req.params.phoneNumberId);
|
const credentials = await contractService.getSipCredentials(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`,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -442,22 +313,7 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
|
|||||||
|
|
||||||
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
|
const cockpitData = await contractCockpitService.getCockpitData();
|
||||||
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
|
|
||||||
// (Pentest Runde 4, 2026-05-16: HOCH).
|
|
||||||
let customerIds: number[] | undefined;
|
|
||||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
|
||||||
customerIds = [req.user.customerId];
|
|
||||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
|
||||||
for (const repCustId of representedIds) {
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
|
||||||
if (hasAuth) {
|
|
||||||
customerIds.push(repCustId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
|
|
||||||
res.json({ success: true, data: cockpitData } as ApiResponse);
|
res.json({ success: true, data: cockpitData } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cockpit error:', error);
|
console.error('Cockpit error:', error);
|
||||||
@@ -539,7 +395,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
try {
|
try {
|
||||||
const contractMeterId = parseInt(req.params.contractMeterId);
|
const contractMeterId = parseInt(req.params.contractMeterId);
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'ContractMeter',
|
req, action: 'DELETE', resourceType: 'ContractMeter',
|
||||||
@@ -560,8 +415,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const documents = await prisma.contractDocument.findMany({
|
const documents = await prisma.contractDocument.findMany({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -575,8 +428,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
|
|||||||
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
const { documentType, notes } = req.body;
|
||||||
const { documentType, notes, deliveryDate } = req.body;
|
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||||
@@ -609,9 +461,6 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
customerId: contract?.customerId,
|
customerId: contract?.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
|
||||||
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -625,7 +474,6 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const documentId = parseInt(req.params.documentId);
|
const documentId = parseInt(req.params.documentId);
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
||||||
if (!doc || doc.contractId !== contractId) {
|
if (!doc || doc.contractId !== contractId) {
|
||||||
@@ -663,10 +511,9 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
|
// ==================== 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, id))) return;
|
|
||||||
const { nextReviewDate, months } = req.body;
|
const { nextReviewDate, months } = req.body;
|
||||||
|
|
||||||
let reviewDate: Date | null = null;
|
let reviewDate: Date | null = null;
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { Request, Response } from 'express';
|
|||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
const entries = await contractHistoryService.getHistoryEntries(contractId);
|
||||||
res.json({ success: true, data: entries } as ApiResponse);
|
res.json({ success: true, data: entries } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -21,7 +19,6 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
|
|||||||
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const { title, description } = req.body;
|
const { title, description } = req.body;
|
||||||
|
|
||||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
@@ -57,7 +54,6 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const entryId = parseInt(req.params.entryId);
|
const entryId = parseInt(req.params.entryId);
|
||||||
const { title, description } = req.body;
|
const { title, description } = req.body;
|
||||||
|
|
||||||
@@ -84,7 +80,6 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const entryId = parseInt(req.params.entryId);
|
const entryId = parseInt(req.params.entryId);
|
||||||
|
|
||||||
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
|
||||||
|
|||||||
@@ -5,30 +5,19 @@ import * as customerService from '../services/customer.service.js';
|
|||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
// ==================== ALL TASKS (Dashboard & Task List) ====================
|
||||||
|
|
||||||
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { status, customerId } = req.query;
|
const { status, customerId } = req.query;
|
||||||
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
|
|
||||||
|
|
||||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
|
||||||
let customerPortalCustomerIds: number[] | undefined;
|
let customerPortalCustomerIds: number[] | undefined;
|
||||||
let customerPortalEmails: string[] | undefined;
|
let customerPortalEmails: string[] | undefined;
|
||||||
|
|
||||||
if (allowedIds) {
|
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||||
// Wenn der Portal-User explizit nach einer customerId filtert, die er
|
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||||
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
|
|
||||||
// (Pentest Runde 10 – LOW: konsistentes Response-Verhalten nach
|
|
||||||
// Vollmacht-Widerruf).
|
|
||||||
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
|
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
customerPortalCustomerIds = allowedIds;
|
|
||||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
@@ -37,7 +26,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
const tasks = await contractTaskService.getAllTasks({
|
const tasks = await contractTaskService.getAllTasks({
|
||||||
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
status: status as 'OPEN' | 'COMPLETED' | undefined,
|
||||||
customerId: customerIdNum,
|
customerId: customerId ? parseInt(customerId as string) : undefined,
|
||||||
customerPortalCustomerIds,
|
customerPortalCustomerIds,
|
||||||
customerPortalEmails,
|
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> {
|
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
|
// Für Kundenportal: Filter auf erlaubte Kunden
|
||||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
|
||||||
let customerPortalCustomerIds: number[] | undefined;
|
let customerPortalCustomerIds: number[] | undefined;
|
||||||
let customerPortalEmails: string[] | undefined;
|
let customerPortalEmails: string[] | undefined;
|
||||||
|
|
||||||
if (allowedIds) {
|
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||||
customerPortalCustomerIds = allowedIds;
|
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||||
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
@@ -87,17 +75,33 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
|
|
||||||
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
|
// Prüfe Zugriff auf den Vertrag
|
||||||
// hasAuthorization (Pentest Runde 6 – HOCH-04: widerrufene Vollmachten
|
const contract = await contractService.getContractById(contractId);
|
||||||
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
|
if (!contract) {
|
||||||
// konsultiert wurde, ohne Status-Check).
|
res.status(404).json({
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
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;
|
let customerPortalEmails: string[] | undefined;
|
||||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||||
if (allowedIds) {
|
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
|
||||||
const customers = await customerService.getCustomersByIds(allowedIds);
|
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
|
||||||
customerPortalEmails = customers
|
customerPortalEmails = customers
|
||||||
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
|
||||||
.filter((email: string | null): email is string => !!email);
|
.filter((email: string | null): email is string => !!email);
|
||||||
@@ -183,8 +187,27 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
|
// Prüfe Zugriff auf den Vertrag
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
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;
|
const createdBy = req.user?.email;
|
||||||
|
|
||||||
@@ -353,7 +376,24 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
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({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Nur für Kundenportal-Benutzer',
|
error: 'Nur für Kundenportal-Benutzer',
|
||||||
@@ -361,27 +401,6 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
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 createdBy = req.user?.email;
|
||||||
|
|
||||||
const subtask = await contractTaskService.createSubtask({
|
const subtask = await contractTaskService.createSubtask({
|
||||||
|
|||||||
@@ -3,50 +3,19 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
|
||||||
sanitizeCustomer,
|
|
||||||
sanitizeCustomers,
|
|
||||||
sanitizeCustomerStrict,
|
|
||||||
pickCustomerCreate,
|
|
||||||
pickCustomerUpdate,
|
|
||||||
} from '../utils/sanitize.js';
|
|
||||||
import {
|
|
||||||
canAccessMeter,
|
|
||||||
canAccessAddress,
|
|
||||||
canAccessBankCard,
|
|
||||||
canAccessIdentityDocument,
|
|
||||||
canAccessCustomer,
|
|
||||||
getPortalAllowedCustomerIds,
|
|
||||||
} from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { search, type, page, limit } = req.query;
|
const { search, type, page, limit } = req.query;
|
||||||
|
|
||||||
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
|
|
||||||
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
|
|
||||||
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
|
|
||||||
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
|
|
||||||
const allowedIds = await getPortalAllowedCustomerIds(req);
|
|
||||||
|
|
||||||
const result = await customerService.getAllCustomers({
|
const result = await customerService.getAllCustomers({
|
||||||
search: search as string,
|
search: search as string,
|
||||||
type: type as 'PRIVATE' | 'BUSINESS',
|
type: type as 'PRIVATE' | 'BUSINESS',
|
||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
allowedIds: allowedIds ?? undefined,
|
|
||||||
});
|
});
|
||||||
const customers = result.customers as any[];
|
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -55,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 {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const customer = await customerService.getCustomerById(customerId);
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
|
res.json({ success: true, data: customer } as ApiResponse);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
||||||
}
|
}
|
||||||
@@ -77,8 +39,7 @@ export async function getCustomer(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
const data = { ...req.body };
|
||||||
const data: any = pickCustomerCreate(req.body);
|
|
||||||
// Convert birthDate string to Date if present
|
// Convert birthDate string to Date if present
|
||||||
if (data.birthDate) {
|
if (data.birthDate) {
|
||||||
data.birthDate = new Date(data.birthDate);
|
data.birthDate = new Date(data.birthDate);
|
||||||
@@ -102,8 +63,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const data = { ...req.body };
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
@@ -200,21 +160,18 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Addresses
|
// Addresses
|
||||||
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
|
export async function getAddresses(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const addresses = await customerService.getCustomerAddresses(customerId);
|
|
||||||
res.json({ success: true, data: addresses } as ApiResponse);
|
res.json({ success: true, data: addresses } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
|
export async function createAddress(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const address = await customerService.createAddress(customerId, req.body);
|
const address = await customerService.createAddress(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Address',
|
req, action: 'CREATE', resourceType: 'Address',
|
||||||
@@ -231,10 +188,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 {
|
try {
|
||||||
const addressId = parseInt(req.params.id);
|
const addressId = parseInt(req.params.id);
|
||||||
if (!(await canAccessAddress(req, res, addressId))) return;
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -295,10 +251,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 {
|
try {
|
||||||
const addressId = parseInt(req.params.id);
|
const addressId = parseInt(req.params.id);
|
||||||
if (!(await canAccessAddress(req, res, addressId))) return;
|
|
||||||
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
|
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
|
||||||
const customerId = addr?.customerId;
|
const customerId = addr?.customerId;
|
||||||
await customerService.deleteAddress(addressId);
|
await customerService.deleteAddress(addressId);
|
||||||
@@ -318,22 +273,22 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bank Cards
|
// Bank Cards
|
||||||
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
|
export async function getBankCards(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
|
const cards = await customerService.getCustomerBankCards(
|
||||||
|
parseInt(req.params.customerId),
|
||||||
|
showInactive
|
||||||
|
);
|
||||||
res.json({ success: true, data: cards } as ApiResponse);
|
res.json({ success: true, data: cards } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
|
export async function createBankCard(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const card = await customerService.createBankCard(customerId, req.body);
|
const card = await customerService.createBankCard(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
@@ -350,10 +305,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 {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -409,10 +363,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 {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
|
||||||
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
|
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
|
||||||
const customerId = card?.customerId;
|
const customerId = card?.customerId;
|
||||||
await customerService.deleteBankCard(cardId);
|
await customerService.deleteBankCard(cardId);
|
||||||
@@ -432,22 +385,22 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Identity Documents
|
// Identity Documents
|
||||||
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
|
export async function getDocuments(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
|
const docs = await customerService.getCustomerDocuments(
|
||||||
|
parseInt(req.params.customerId),
|
||||||
|
showInactive
|
||||||
|
);
|
||||||
res.json({ success: true, data: docs } as ApiResponse);
|
res.json({ success: true, data: docs } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
|
export async function createDocument(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const doc = await customerService.createDocument(customerId, req.body);
|
const doc = await customerService.createDocument(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
||||||
@@ -464,10 +417,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 {
|
try {
|
||||||
const docId = parseInt(req.params.id);
|
const docId = parseInt(req.params.id);
|
||||||
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -529,10 +481,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 {
|
try {
|
||||||
const docId = parseInt(req.params.id);
|
const docId = parseInt(req.params.id);
|
||||||
if (!(await canAccessIdentityDocument(req, res, docId))) return;
|
|
||||||
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
|
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
|
||||||
const customerId = doc?.customerId;
|
const customerId = doc?.customerId;
|
||||||
await customerService.deleteDocument(docId);
|
await customerService.deleteDocument(docId);
|
||||||
@@ -552,22 +503,22 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meters
|
// Meters
|
||||||
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
|
export async function getMeters(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
const showInactive = req.query.showInactive === 'true';
|
||||||
const meters = await customerService.getCustomerMeters(customerId, showInactive);
|
const meters = await customerService.getCustomerMeters(
|
||||||
|
parseInt(req.params.customerId),
|
||||||
|
showInactive
|
||||||
|
);
|
||||||
res.json({ success: true, data: meters } as ApiResponse);
|
res.json({ success: true, data: meters } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
|
export async function createMeter(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const meter = await customerService.createMeter(customerId, req.body);
|
const meter = await customerService.createMeter(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Meter',
|
req, action: 'CREATE', resourceType: 'Meter',
|
||||||
@@ -584,10 +535,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 {
|
try {
|
||||||
const meterId = parseInt(req.params.id);
|
const meterId = parseInt(req.params.id);
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
@@ -642,10 +592,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 {
|
try {
|
||||||
const meterId = parseInt(req.params.id);
|
const meterId = parseInt(req.params.id);
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
await customerService.deleteMeter(meterId);
|
await customerService.deleteMeter(meterId);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'Meter',
|
req, action: 'DELETE', resourceType: 'Meter',
|
||||||
@@ -662,22 +611,19 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meter Readings
|
// Meter Readings
|
||||||
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
|
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const readings = await customerService.getMeterReadings(meterId);
|
|
||||||
res.json({ success: true, data: readings } as ApiResponse);
|
res.json({ success: true, data: readings } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
|
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 {
|
try {
|
||||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||||
const meterId = parseInt(req.params.meterId);
|
const meterId = parseInt(req.params.meterId);
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const reading = await customerService.addMeterReading(meterId, {
|
const reading = await customerService.addMeterReading(meterId, {
|
||||||
readingDate: new Date(readingDate),
|
readingDate: new Date(readingDate),
|
||||||
value: parseFloat(value),
|
value: parseFloat(value),
|
||||||
@@ -710,10 +656,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 {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const { readingDate, value, valueNt, unit, notes } = req.body;
|
const { readingDate, value, valueNt, unit, notes } = req.body;
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
|
||||||
@@ -723,7 +667,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
if (notes !== undefined) updateData.notes = notes;
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
|
||||||
const reading = await customerService.updateMeterReading(
|
const reading = await customerService.updateMeterReading(
|
||||||
meterId,
|
parseInt(req.params.meterId),
|
||||||
parseInt(req.params.readingId),
|
parseInt(req.params.readingId),
|
||||||
updateData as any
|
updateData as any
|
||||||
);
|
);
|
||||||
@@ -741,12 +685,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 {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const readingId = parseInt(req.params.readingId);
|
const readingId = parseInt(req.params.readingId);
|
||||||
await customerService.deleteMeterReading(meterId, readingId);
|
await customerService.deleteMeterReading(
|
||||||
|
parseInt(req.params.meterId),
|
||||||
|
readingId
|
||||||
|
);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'MeterReading',
|
req, action: 'DELETE', resourceType: 'MeterReading',
|
||||||
resourceId: readingId.toString(),
|
resourceId: readingId.toString(),
|
||||||
@@ -847,7 +792,6 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
|
|||||||
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
const meterId = parseInt(req.params.meterId);
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const readingId = parseInt(req.params.readingId);
|
const readingId = parseInt(req.params.readingId);
|
||||||
|
|
||||||
const reading = await prisma.meterReading.update({
|
const reading = await prisma.meterReading.update({
|
||||||
@@ -876,11 +820,9 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
// ==================== PORTAL SETTINGS ====================
|
// ==================== PORTAL SETTINGS ====================
|
||||||
|
|
||||||
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
|
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const settings = await customerService.getPortalSettings(customerId);
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -967,115 +909,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> {
|
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
|
if (!password || password.length < 6) {
|
||||||
const complexity = validatePasswordComplexity(password);
|
|
||||||
if (!complexity.ok) {
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
|
error: 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1096,22 +936,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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const password = await authService.getCustomerPortalPassword(parseInt(req.params.customerId));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const password = await authService.getCustomerPortalPassword(customerId);
|
|
||||||
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
|
||||||
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
|
||||||
// + Insider-Threat-Erkennung.
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'READ',
|
|
||||||
resourceType: 'PortalPassword',
|
|
||||||
resourceId: customerId.toString(),
|
|
||||||
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
|
|
||||||
customerId,
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -1123,12 +950,10 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
|||||||
|
|
||||||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||||||
|
|
||||||
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
|
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
// Wer kann diesen Kunden vertreten (representedBy)?
|
// Wer kann diesen Kunden vertreten (representedBy)?
|
||||||
const representedBy = await customerService.getRepresentedByList(customerId);
|
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
|
||||||
res.json({ success: true, data: representedBy } as ApiResponse);
|
res.json({ success: true, data: representedBy } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -1138,10 +963,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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const { representativeId, notes } = req.body;
|
const { representativeId, notes } = req.body;
|
||||||
const representative = await customerService.addRepresentative(
|
const representative = await customerService.addRepresentative(
|
||||||
customerId,
|
customerId,
|
||||||
@@ -1163,10 +987,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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
await customerService.removeRepresentative(
|
await customerService.removeRepresentative(
|
||||||
customerId,
|
customerId,
|
||||||
parseInt(req.params.representativeId)
|
parseInt(req.params.representativeId)
|
||||||
@@ -1185,13 +1008,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 {
|
try {
|
||||||
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
|
|
||||||
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
|
|
||||||
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
|
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const { search } = req.query;
|
const { search } = req.query;
|
||||||
if (!search || typeof search !== 'string' || search.length < 2) {
|
if (!search || typeof search !== 'string' || search.length < 2) {
|
||||||
res.json({ success: true, data: [] } as ApiResponse);
|
res.json({ success: true, data: [] } as ApiResponse);
|
||||||
@@ -1199,7 +1017,7 @@ export async function searchForRepresentative(req: AuthRequest, res: Response):
|
|||||||
}
|
}
|
||||||
const customers = await customerService.searchCustomersForRepresentative(
|
const customers = await customerService.searchCustomersForRepresentative(
|
||||||
search,
|
search,
|
||||||
customerId,
|
parseInt(req.params.customerId)
|
||||||
);
|
);
|
||||||
res.json({ success: true, data: customers } as ApiResponse);
|
res.json({ success: true, data: customers } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { ApiResponse } from '../types/index.js';
|
|||||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
|
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -120,33 +118,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
domain: req.body.domain,
|
domain: req.body.domain,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
|
||||||
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen – ohne dass
|
|
||||||
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
|
||||||
if (testData?.apiUrl) {
|
|
||||||
try {
|
|
||||||
const url = new URL(testData.apiUrl);
|
|
||||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'SSRF_BLOCKED',
|
|
||||||
severity: 'HIGH',
|
|
||||||
message: err.message,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userId: ctx.userId,
|
|
||||||
userEmail: ctx.userEmail,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
details: { apiUrl: testData.apiUrl },
|
|
||||||
});
|
|
||||||
res.status(400).json({ success: false, error: err.message } as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// URL-Parse-Fehler ignorieren – Backend reagiert sowieso mit Fehler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -243,56 +214,24 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
|
|
||||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
|
||||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
|
||||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
|
||||||
let smtpResolved: { ip: string; servername: string };
|
|
||||||
let imapResolved: { ip: string; servername: string };
|
|
||||||
try {
|
|
||||||
[smtpResolved, imapResolved] = await Promise.all([
|
|
||||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
|
||||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'SSRF_BLOCKED',
|
|
||||||
severity: 'HIGH',
|
|
||||||
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userId: ctx.userId,
|
|
||||||
userEmail: ctx.userEmail,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
details: { smtpServer, imapServer },
|
|
||||||
});
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMAP testen
|
// IMAP testen
|
||||||
const imapCredentials: ImapCredentials = {
|
const imapCredentials: ImapCredentials = {
|
||||||
host: imapResolved.ip,
|
host: imapServer,
|
||||||
port: imapPort,
|
port: imapPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: imapEncryption,
|
encryption: imapEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
servername: imapResolved.servername,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// SMTP testen
|
// SMTP testen
|
||||||
const smtpCredentials: SmtpCredentials = {
|
const smtpCredentials: SmtpCredentials = {
|
||||||
host: smtpResolved.ip,
|
host: smtpServer,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
user: emailAddress,
|
user: emailAddress,
|
||||||
password,
|
password,
|
||||||
encryption: smtpEncryption,
|
encryption: smtpEncryption,
|
||||||
allowSelfSignedCerts,
|
allowSelfSignedCerts,
|
||||||
servername: smtpResolved.servername,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let imapResult: { success: boolean; error?: string } = { success: false };
|
let imapResult: { success: boolean; error?: string } = { success: false };
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
appSettings: data.appSettings.length,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -63,39 +62,3 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
|
|
||||||
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
|
|
||||||
*/
|
|
||||||
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
|
||||||
try {
|
|
||||||
const file = (req as any).file as Express.Multer.File | undefined;
|
|
||||||
if (!file || !file.buffer) {
|
|
||||||
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: req.user?.userId,
|
|
||||||
userEmail: req.user?.email || 'unknown',
|
|
||||||
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
|
|
||||||
// den Vorgang explizit als Import.
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'FactoryDefaults',
|
|
||||||
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
|
|
||||||
endpoint: req.path,
|
|
||||||
httpMethod: req.method,
|
|
||||||
ipAddress: req.socket.remoteAddress || 'unknown',
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Factory-Defaults-Import:', error);
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,84 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
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 consentService from '../services/consent.service.js';
|
||||||
import * as consentPublicService from '../services/consent-public.service.js';
|
import * as consentPublicService from '../services/consent-public.service.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
import { canAccessCustomer } from '../utils/accessControl.js';
|
|
||||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
@@ -191,12 +190,7 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
|
|||||||
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
|
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 filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||||
@@ -230,7 +224,6 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
|
|||||||
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const consents = await consentService.getCustomerConsents(customerId);
|
const consents = await consentService.getCustomerConsents(customerId);
|
||||||
|
|
||||||
// Labels hinzufügen
|
// Labels hinzufügen
|
||||||
@@ -253,7 +246,6 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
|||||||
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const result = await consentService.hasFullConsent(customerId);
|
const result = await consentService.hasFullConsent(customerId);
|
||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -279,9 +271,17 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
|
// Portal: nur eigene + vertretene Kunden
|
||||||
// widerrufene Vollmachten hatten vorher noch Zugriff)
|
const allowed = [
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
(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)) {
|
if (!Object.values(ConsentType).includes(consentType)) {
|
||||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||||
@@ -794,7 +794,6 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
||||||
await authorizationService.ensureAuthorizationEntries(customerId);
|
await authorizationService.ensureAuthorizationEntries(customerId);
|
||||||
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
||||||
@@ -962,27 +961,12 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
|
|||||||
const representativeId = parseInt(req.params.representativeId);
|
const representativeId = parseInt(req.params.representativeId);
|
||||||
const { grant } = req.body;
|
const { grant } = req.body;
|
||||||
|
|
||||||
// Validierungen:
|
// Vertreter-Name laden
|
||||||
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
|
const representative = await prisma.customer.findUnique({
|
||||||
if (representativeId === user.customerId) {
|
where: { id: representativeId },
|
||||||
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
|
select: { firstName: true, lastName: true },
|
||||||
}
|
|
||||||
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
|
|
||||||
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
|
|
||||||
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
|
|
||||||
// DB enumerieren kann (kein 404-vs-403-Disclosure).
|
|
||||||
const relation = await prisma.customerRepresentative.findFirst({
|
|
||||||
where: { customerId: user.customerId, representativeId, isActive: true },
|
|
||||||
include: { representative: { select: { firstName: true, lastName: true } } },
|
|
||||||
});
|
});
|
||||||
if (!relation) {
|
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Kein Vertreter-Verhältnis – Vollmacht nicht erlaubt',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
|
|
||||||
|
|
||||||
let auth;
|
let auth;
|
||||||
if (grant) {
|
if (grant) {
|
||||||
@@ -998,9 +982,10 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Ändern der Vollmacht:', error);
|
console.error('Fehler beim Ändern der Vollmacht:', error);
|
||||||
// Generische Fehlermeldung – Prisma-Errors enthalten Pfad/Schema und
|
res.status(400).json({
|
||||||
// sollten nicht an Endkunden geleakt werden.
|
success: false,
|
||||||
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
|
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||||
*/
|
*/
|
||||||
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
|
export async function getInvoices(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const invoices = await invoiceService.getInvoices(ecdId);
|
const invoices = await invoiceService.getInvoices(ecdId);
|
||||||
res.json({ success: true, data: invoices } as ApiResponse);
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -25,11 +23,10 @@ export async function getInvoices(req: AuthRequest, res: Response): Promise<void
|
|||||||
/**
|
/**
|
||||||
* Einzelne Rechnung abrufen
|
* Einzelne Rechnung abrufen
|
||||||
*/
|
*/
|
||||||
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function getInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
@@ -53,10 +50,9 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Neue Rechnung hinzufügen
|
* Neue Rechnung hinzufügen
|
||||||
*/
|
*/
|
||||||
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function addInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
if (!invoiceDate || !invoiceType) {
|
if (!invoiceDate || !invoiceType) {
|
||||||
@@ -93,11 +89,10 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Rechnung aktualisieren
|
* Rechnung aktualisieren
|
||||||
*/
|
*/
|
||||||
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
@@ -126,11 +121,10 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
/**
|
/**
|
||||||
* Rechnung löschen
|
* Rechnung löschen
|
||||||
*/
|
*/
|
||||||
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
|
|
||||||
await invoiceService.deleteInvoice(ecdId, invoiceId);
|
await invoiceService.deleteInvoice(ecdId, invoiceId);
|
||||||
|
|
||||||
@@ -152,10 +146,9 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
|
|
||||||
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
||||||
|
|
||||||
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
||||||
res.json({ success: true, data: invoices } as ApiResponse);
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -163,10 +156,9 @@ export async function getInvoicesByContract(req: AuthRequest, res: Response): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function addInvoiceByContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import { Response } from 'express';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
|
||||||
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
|
|
||||||
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/monitoring/events
|
|
||||||
* Liste der Security-Events mit Filter + Pagination.
|
|
||||||
*/
|
|
||||||
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const page = parseInt((req.query.page as string) || '1');
|
|
||||||
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
|
|
||||||
const type = req.query.type as SecurityEventType | undefined;
|
|
||||||
const severity = req.query.severity as SecuritySeverity | undefined;
|
|
||||||
const search = req.query.search as string | undefined;
|
|
||||||
const since = req.query.since as string | undefined;
|
|
||||||
const ip = req.query.ip as string | undefined;
|
|
||||||
|
|
||||||
const where: any = {};
|
|
||||||
if (type) where.type = type;
|
|
||||||
if (severity) where.severity = severity;
|
|
||||||
if (ip) where.ipAddress = ip;
|
|
||||||
if (since) where.createdAt = { gte: new Date(since) };
|
|
||||||
if (search) {
|
|
||||||
where.OR = [
|
|
||||||
{ message: { contains: search } },
|
|
||||||
{ userEmail: { contains: search } },
|
|
||||||
{ endpoint: { contains: search } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [events, total, byType, bySeverity] = await Promise.all([
|
|
||||||
prisma.securityEvent.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
skip: (page - 1) * limit,
|
|
||||||
}),
|
|
||||||
prisma.securityEvent.count({ where }),
|
|
||||||
prisma.securityEvent.groupBy({
|
|
||||||
by: ['type'],
|
|
||||||
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
prisma.securityEvent.groupBy({
|
|
||||||
by: ['severity'],
|
|
||||||
where: since ? { createdAt: { gte: new Date(since) } } : {},
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: events,
|
|
||||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
||||||
stats: {
|
|
||||||
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
|
|
||||||
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('listEvents error:', error);
|
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/monitoring/settings
|
|
||||||
*/
|
|
||||||
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
|
||||||
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
|
||||||
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
alertEmail: alertEmail || '',
|
|
||||||
digestEnabled,
|
|
||||||
lastDigestAt: lastDigest || null,
|
|
||||||
},
|
|
||||||
} as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/monitoring/settings
|
|
||||||
*/
|
|
||||||
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { alertEmail, digestEnabled } = req.body || {};
|
|
||||||
if (typeof alertEmail === 'string') {
|
|
||||||
// Email-Validierung minimal: muss @ enthalten oder leer sein
|
|
||||||
if (alertEmail !== '' && !alertEmail.includes('@')) {
|
|
||||||
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
|
|
||||||
}
|
|
||||||
if (typeof digestEnabled === 'boolean') {
|
|
||||||
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/monitoring/test-alert
|
|
||||||
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
|
|
||||||
*/
|
|
||||||
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
|
||||||
if (!alertEmail) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Keine Alert-E-Mail konfiguriert',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await sendAlertEmail(alertEmail, {
|
|
||||||
subject: '[OpenCRM] Test-Alert',
|
|
||||||
events: [{
|
|
||||||
type: 'SUSPICIOUS' as any,
|
|
||||||
severity: 'INFO' as any,
|
|
||||||
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as any],
|
|
||||||
isDigest: false,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/monitoring/events
|
|
||||||
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
|
|
||||||
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
|
|
||||||
* Audit-Trail erhalten bleibt.
|
|
||||||
*/
|
|
||||||
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const olderThanDays = req.query.olderThanDays
|
|
||||||
? parseInt(req.query.olderThanDays as string)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const where: any = {};
|
|
||||||
if (olderThanDays && olderThanDays > 0) {
|
|
||||||
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
|
||||||
where.createdAt = { lt: cutoff };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.securityEvent.deleteMany({ where });
|
|
||||||
|
|
||||||
// Audit-Spur: Wer hat geleert
|
|
||||||
const user = (req as any).user;
|
|
||||||
await prisma.securityEvent.create({
|
|
||||||
data: {
|
|
||||||
type: 'PERMISSION_CHANGED',
|
|
||||||
severity: 'INFO',
|
|
||||||
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
|
|
||||||
userId: user?.userId || null,
|
|
||||||
userEmail: user?.email || null,
|
|
||||||
ipAddress: req.ip || 'unknown',
|
|
||||||
endpoint: 'DELETE /api/monitoring/events',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `${result.count} Events gelöscht`,
|
|
||||||
data: { deletedCount: result.count },
|
|
||||||
} as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('clearEvents error:', error);
|
|
||||||
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
|
|
||||||
*/
|
|
||||||
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const result = await sendDigest({ force: true });
|
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { Response } from 'express';
|
|||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
export async function getTemplates(req: AuthRequest, res: Response) {
|
export async function getTemplates(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +149,6 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const templateId = parseInt(req.params.id);
|
const templateId = parseInt(req.params.id);
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||||
res.json({ success: true, data: inputs });
|
res.json({ success: true, data: inputs });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,7 +160,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const templateId = parseInt(req.params.id);
|
const templateId = parseInt(req.params.id);
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
// Extras aus Body (POST) oder Query-Parametern (GET)
|
// Extras aus Body (POST) oder Query-Parametern (GET)
|
||||||
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { Response } from 'express';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
|
||||||
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
|
||||||
import { logChange } from '../services/audit.service.js';
|
|
||||||
|
|
||||||
// Login-Rate-Limiter sperrt 15 Minuten. Wir betrachten alles, was innerhalb
|
|
||||||
// dieses Fensters einen RATE_LIMIT_HIT erzeugt hat, als „aktuell gesperrt".
|
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listet alle IP-Adressen, die in den letzten 15 Minuten den Login-Rate-
|
|
||||||
* Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits,
|
|
||||||
* (letzte) versuchte E-Mail.
|
|
||||||
*/
|
|
||||||
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
|
|
||||||
const events = await prisma.securityEvent.findMany({
|
|
||||||
where: {
|
|
||||||
type: 'RATE_LIMIT_HIT',
|
|
||||||
createdAt: { gte: since },
|
|
||||||
ipAddress: { not: null },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
select: {
|
|
||||||
ipAddress: true,
|
|
||||||
userEmail: true,
|
|
||||||
endpoint: true,
|
|
||||||
createdAt: true,
|
|
||||||
details: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pro IP gruppieren: lastHit + hitCount + zuletzt versuchte Email + Limiter-Typ
|
|
||||||
type Active = {
|
|
||||||
ipAddress: string;
|
|
||||||
lastHit: Date;
|
|
||||||
hitCount: number;
|
|
||||||
lastEmail: string | null;
|
|
||||||
lastEndpoint: string | null;
|
|
||||||
limiters: string[]; // 'login' / 'password-reset'
|
|
||||||
};
|
|
||||||
const byIp = new Map<string, Active>();
|
|
||||||
for (const ev of events) {
|
|
||||||
const ip = ev.ipAddress!;
|
|
||||||
const limiter = (ev.details as any)?.limiter ?? 'unknown';
|
|
||||||
const existing = byIp.get(ip);
|
|
||||||
if (existing) {
|
|
||||||
existing.hitCount += 1;
|
|
||||||
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
|
|
||||||
} else {
|
|
||||||
byIp.set(ip, {
|
|
||||||
ipAddress: ip,
|
|
||||||
lastHit: ev.createdAt,
|
|
||||||
hitCount: 1,
|
|
||||||
lastEmail: ev.userEmail,
|
|
||||||
lastEndpoint: ev.endpoint,
|
|
||||||
limiters: [limiter],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bereits manuell freigegebene IPs aus der Anzeige rauswerfen: wenn der
|
|
||||||
// letzte Reset (= Audit-Log-Eintrag) NACH dem letzten Hit liegt, ist die
|
|
||||||
// IP nicht mehr gesperrt. SecurityEvents sind unveränderlich, also brauchen
|
|
||||||
// wir diesen Reset-Marker, sonst bleibt eine bereits freigegebene IP
|
|
||||||
// weiterhin im Bildschirm hängen, bis das 15-Min-Fenster abgelaufen ist.
|
|
||||||
const candidateIps = Array.from(byIp.keys());
|
|
||||||
if (candidateIps.length > 0) {
|
|
||||||
const recentResets = await prisma.auditLog.findMany({
|
|
||||||
where: {
|
|
||||||
resourceType: 'RateLimit',
|
|
||||||
resourceId: { in: candidateIps },
|
|
||||||
createdAt: { gte: since },
|
|
||||||
},
|
|
||||||
select: { resourceId: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
const resetMap = new Map<string, Date>();
|
|
||||||
for (const r of recentResets) {
|
|
||||||
if (r.resourceId && !resetMap.has(r.resourceId)) {
|
|
||||||
resetMap.set(r.resourceId, r.createdAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const ip of candidateIps) {
|
|
||||||
const reset = resetMap.get(ip);
|
|
||||||
const entry = byIp.get(ip)!;
|
|
||||||
if (reset && reset >= entry.lastHit) {
|
|
||||||
byIp.delete(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = Array.from(byIp.values()).sort(
|
|
||||||
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: list } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('getActiveRateLimits error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Fehler beim Laden der aktiven Rate-Limits',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setzt das Rate-Limit für eine konkrete IP zurück (Login + Password-Reset).
|
|
||||||
* Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach
|
|
||||||
* ohne Wirkung.
|
|
||||||
*/
|
|
||||||
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const ip = (req.body?.ipAddress || '').toString().trim();
|
|
||||||
if (!ip) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'IP-Adresse fehlt',
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle.
|
|
||||||
// Falls die IP nicht im Store ist, ist das ein No-Op.
|
|
||||||
await (loginRateLimiter as any).resetKey?.(ip);
|
|
||||||
await (passwordResetRateLimiter as any).resetKey?.(ip);
|
|
||||||
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'RateLimit',
|
|
||||||
resourceId: ip,
|
|
||||||
label: `Rate-Limit für IP ${ip} manuell freigegeben`,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true, message: `Rate-Limit für ${ip} freigegeben` } as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('resetRateLimit error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Fehler beim Zurücksetzen des Rate-Limits',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -18,12 +17,9 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -31,13 +27,7 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
// Sensibles Feld emailPasswordEncrypted nie an Portal-Kunden geben
|
|
||||||
const sanitized: any = { ...email };
|
|
||||||
if (req.user?.isCustomerPortal) {
|
|
||||||
delete sanitized.emailPasswordEncrypted;
|
|
||||||
}
|
|
||||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -68,11 +58,9 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const email = await stressfreiEmailService.updateEmail(parseInt(req.params.id), req.body);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'StressfreiEmail',
|
req, action: 'UPDATE', resourceType: 'StressfreiEmail',
|
||||||
resourceId: email.id.toString(),
|
resourceId: email.id.toString(),
|
||||||
@@ -87,10 +75,9 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = parseInt(req.params.id);
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
await stressfreiEmailService.deleteEmail(emailId);
|
await stressfreiEmailService.deleteEmail(emailId);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'DELETE', resourceType: 'StressfreiEmail',
|
req, action: 'DELETE', resourceType: 'StressfreiEmail',
|
||||||
@@ -106,50 +93,9 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
export async function resetPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id));
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
|
||||||
if (!result.success) {
|
|
||||||
res.status(400).json({ success: false, error: result.error } as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelParts = [`Weiterleitungen: ${(result.forwardTargets || []).join(', ')}`];
|
|
||||||
if (result.passwordReset) labelParts.push('Mailbox-Passwort am Provider neu gesetzt');
|
|
||||||
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'StressfreiEmail',
|
|
||||||
resourceId: emailId.toString(),
|
|
||||||
label: `Stressfrei-Sync: ${labelParts.join(' | ')}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
forwardTargets: result.forwardTargets,
|
|
||||||
customerEmail: result.customerEmail,
|
|
||||||
passwordReset: result.passwordReset,
|
|
||||||
},
|
|
||||||
message: 'Weiterleitungen aktualisiert',
|
|
||||||
} as ApiResponse);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const emailId = parseInt(req.params.id);
|
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
|
|
||||||
import { validatePasswordComplexity } from '../utils/passwordGenerator.js';
|
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export async function getUsers(req: Request, res: Response): Promise<void> {
|
export async function getUsers(req: Request, res: Response): Promise<void> {
|
||||||
@@ -51,19 +49,7 @@ export async function getUser(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createUser(req: Request, res: Response): Promise<void> {
|
export async function createUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const user = await userService.createUser(req.body);
|
||||||
const data = pickUserCreate(req.body) as any;
|
|
||||||
if (data?.password) {
|
|
||||||
const c = validatePasswordComplexity(data.password);
|
|
||||||
if (!c.ok) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const user = await userService.createUser(data);
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -81,41 +67,18 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const data = req.body;
|
||||||
const data = pickUserUpdate(req.body);
|
|
||||||
if ((data as any)?.password) {
|
|
||||||
const c = validatePasswordComplexity((data as any).password);
|
|
||||||
if (!c.ok) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
|
|
||||||
} as ApiResponse);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
// Vorherigen Stand laden für Audit
|
||||||
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
const before = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
const beforeUser = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: { roles: { include: { role: true } } },
|
|
||||||
});
|
|
||||||
const before = beforeUser
|
|
||||||
? {
|
|
||||||
...beforeUser,
|
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data as any);
|
const user = await userService.updateUser(userId, data);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
|
|||||||
+12
-287
@@ -1,34 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import dotenvExpand from 'dotenv-expand';
|
|
||||||
|
|
||||||
// .env-Dateien laden – Root-.env hat Priorität (zentrale Konfiguration für
|
|
||||||
// Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind
|
|
||||||
// Variablen schon via env_file/environment gesetzt – dotenv überschreibt
|
|
||||||
// existierende process.env-Werte nicht.
|
|
||||||
// __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen
|
|
||||||
// liegt Root /.env zwei Ebenen darüber.
|
|
||||||
//
|
|
||||||
// dotenvExpand löst ${VAR}-Substitution auf, sodass z.B.
|
|
||||||
// DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
|
||||||
// dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen).
|
|
||||||
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') }));
|
|
||||||
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') }));
|
|
||||||
dotenvExpand.expand(dotenv.config());
|
|
||||||
|
|
||||||
// Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution
|
|
||||||
// nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen.
|
|
||||||
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
|
|
||||||
const u = encodeURIComponent(process.env.DB_USER);
|
|
||||||
const p = encodeURIComponent(process.env.DB_PASSWORD);
|
|
||||||
const h = process.env.DB_HOST || 'localhost';
|
|
||||||
const port = process.env.DB_PORT || '3306';
|
|
||||||
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
import authRoutes from './routes/auth.routes.js';
|
import authRoutes from './routes/auth.routes.js';
|
||||||
import customerRoutes from './routes/customer.routes.js';
|
import customerRoutes from './routes/customer.routes.js';
|
||||||
@@ -60,229 +33,24 @@ import emailLogRoutes from './routes/emailLog.routes.js';
|
|||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.routes.js';
|
import birthdayRoutes from './routes/birthday.routes.js';
|
||||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
|
||||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
dotenv.config();
|
||||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
|
|
||||||
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
|
|
||||||
console.error(' Generiere mit: openssl rand -hex 64');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
|
|
||||||
console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)');
|
|
||||||
console.error(' Generiere mit: openssl rand -hex 32');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
|
// Middleware
|
||||||
//
|
app.use(cors());
|
||||||
// Zwei Szenarien:
|
app.use(express.json());
|
||||||
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
|
|
||||||
// EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
|
|
||||||
// genau einem Hop → req.ip = echter Client (nicht der Proxy).
|
|
||||||
// Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
|
|
||||||
// sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
|
|
||||||
// Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
|
|
||||||
// durch das Docker-Network + nicht-veröffentlichten Backend-Port
|
|
||||||
// gewährleistet.
|
|
||||||
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
|
|
||||||
// Zugriff): `loopback` reicht – kein vertrauenswürdiger Hop davor.
|
|
||||||
//
|
|
||||||
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
|
|
||||||
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
|
|
||||||
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
|
|
||||||
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
|
|
||||||
app.set('trust proxy', trustProxyValue);
|
|
||||||
|
|
||||||
// ==================== SECURITY MIDDLEWARE ====================
|
|
||||||
|
|
||||||
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...)
|
|
||||||
//
|
|
||||||
// CSP ist konservativ aber SPA-tauglich:
|
|
||||||
// - script-src 'self' → keine externen Skripte, keine inline-Scripts
|
|
||||||
// (Vite baut Module-Skripte zu separaten Files,
|
|
||||||
// die sind 'self')
|
|
||||||
// - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das
|
|
||||||
// (sicheres Trade-off; XSS via CSS ist
|
|
||||||
// marginal vs Lock-Out gegen die UI)
|
|
||||||
// - img-src self/data/blob → base64-Avatare + blob-URLs für PDFs/Downloads
|
|
||||||
// - font-src self/data → eingebettete Fonts
|
|
||||||
// - connect-src 'self' → API + WebSocket nur zur eigenen Origin
|
|
||||||
// - frame-ancestors 'none' → Clickjacking-Schutz (ersetzt X-Frame-Options)
|
|
||||||
// - object-src 'none' → keine Flash/<object>/<embed>-Embeds
|
|
||||||
// - base-uri 'self' → keine <base>-Hijacking-Tricks
|
|
||||||
// - form-action 'self' → POST-Targets nur auf eigene Origin
|
|
||||||
// Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen.
|
|
||||||
// Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc.
|
|
||||||
// bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten,
|
|
||||||
// fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet.
|
|
||||||
app.use((_req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
[
|
|
||||||
'accelerometer=()',
|
|
||||||
'ambient-light-sensor=()',
|
|
||||||
'autoplay=()',
|
|
||||||
'battery=()',
|
|
||||||
'camera=()',
|
|
||||||
'clipboard-read=()',
|
|
||||||
'clipboard-write=(self)',
|
|
||||||
'cross-origin-isolated=()',
|
|
||||||
'display-capture=()',
|
|
||||||
'encrypted-media=()',
|
|
||||||
'fullscreen=(self)',
|
|
||||||
'geolocation=()',
|
|
||||||
'gyroscope=()',
|
|
||||||
'hid=()',
|
|
||||||
'idle-detection=()',
|
|
||||||
'magnetometer=()',
|
|
||||||
'microphone=()',
|
|
||||||
'midi=()',
|
|
||||||
'payment=()',
|
|
||||||
'picture-in-picture=()',
|
|
||||||
'publickey-credentials-get=()',
|
|
||||||
'screen-wake-lock=()',
|
|
||||||
'sync-xhr=()',
|
|
||||||
'usb=()',
|
|
||||||
'web-share=()',
|
|
||||||
'xr-spatial-tracking=()',
|
|
||||||
].join(', '),
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn
|
|
||||||
// wirklich TLS davor läuft – sonst sperrt sich die App auf direkt-via-IP-
|
|
||||||
// Deployments (Browser versucht /assets/* via https zu laden → SSL-Error).
|
|
||||||
// Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy
|
|
||||||
// (Caddy/Traefik/Nginx) vor OpenCRM steht.
|
|
||||||
const httpsEnabled = process.env.HTTPS_ENABLED === 'true';
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
useDefaults: true,
|
|
||||||
directives: {
|
|
||||||
'default-src': ["'self'"],
|
|
||||||
'script-src': ["'self'"],
|
|
||||||
'style-src': ["'self'", "'unsafe-inline'"],
|
|
||||||
'img-src': ["'self'", 'data:', 'blob:'],
|
|
||||||
'font-src': ["'self'", 'data:'],
|
|
||||||
'connect-src': ["'self'"],
|
|
||||||
// Explizit gesetzt obwohl Fallback auf default-src/script-src greift –
|
|
||||||
// ZAP markiert sonst "No-Fallback-Direktiven" als CSP-Lücke.
|
|
||||||
'worker-src': ["'self'"],
|
|
||||||
'manifest-src': ["'self'"],
|
|
||||||
'media-src': ["'self'"],
|
|
||||||
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
|
|
||||||
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
|
|
||||||
// 'none' würde sogar same-origin blocken und damit die UI brechen.
|
|
||||||
// Externe Sites bleiben weiterhin gesperrt.
|
|
||||||
'frame-ancestors': ["'self'"],
|
|
||||||
'object-src': ["'none'"],
|
|
||||||
'base-uri': ["'self'"],
|
|
||||||
'form-action': ["'self'"],
|
|
||||||
// useDefaults bringt 'upgrade-insecure-requests' selbst mit – explizit
|
|
||||||
// auf null setzen entfernt es aus dem Header (helmet-API).
|
|
||||||
'upgrade-insecure-requests': httpsEnabled ? [] : null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// HSTS NIE in Helmet senden – der vorgelagerte TLS-Reverse-Proxy
|
|
||||||
// (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt
|
|
||||||
// RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt.
|
|
||||||
// HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant.
|
|
||||||
strictTransportSecurity: false,
|
|
||||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben.
|
|
||||||
const corsOrigins = process.env.CORS_ORIGINS
|
|
||||||
? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
|
|
||||||
: process.env.NODE_ENV === 'production'
|
|
||||||
? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert)
|
|
||||||
: true; // Dev: alles erlauben
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
cors({
|
|
||||||
origin: corsOrigins,
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
|
|
||||||
app.use(express.json({ limit: '5mb' }));
|
|
||||||
// Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht
|
|
||||||
// (POST /api/auth/refresh liest ihn aus req.cookies).
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
// Audit-Logging Middleware (DSGVO-konform)
|
// Audit-Logging Middleware (DSGVO-konform)
|
||||||
app.use(auditContextMiddleware);
|
app.use(auditContextMiddleware);
|
||||||
app.use(auditMiddleware);
|
app.use(auditMiddleware);
|
||||||
|
|
||||||
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
// Statische Dateien für Uploads
|
||||||
// `/api/uploads/*` express.static).
|
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||||
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
|
|
||||||
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
|
|
||||||
// und prüft canAccessCustomer/canAccessContract – damit kann ein Portal-Kunde
|
|
||||||
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
|
|
||||||
//
|
|
||||||
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
|
|
||||||
// Request über denselben Owner-Check (kein freier static-Handler mehr).
|
|
||||||
|
|
||||||
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
|
|
||||||
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> – egal ob als
|
|
||||||
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
|
|
||||||
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
|
|
||||||
app.get('/api/files/download', authenticate as any, downloadFile as any);
|
|
||||||
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
|
|
||||||
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
|
|
||||||
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
|
||||||
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
|
|
||||||
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
|
|
||||||
return (downloadFile as any)(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
|
||||||
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
|
||||||
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
|
||||||
// (siehe express.static mit immutable weiter unten).
|
|
||||||
app.use('/api', (_req, res, next) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
|
||||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
|
||||||
//
|
|
||||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
|
||||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
|
||||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
|
||||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
|
||||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
|
||||||
// nicht geblockt.
|
|
||||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
|
||||||
app.use('/api', (req, res, next) => {
|
|
||||||
for (const seg of req.path.split('/')) {
|
|
||||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
|
||||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
app.use('/api/public/consent', consentPublicRoutes);
|
||||||
@@ -317,7 +85,6 @@ app.use('/api/email-logs', emailLogRoutes);
|
|||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
app.use('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
@@ -328,29 +95,8 @@ app.get('/api/health', (req, res) => {
|
|||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(process.cwd(), 'public');
|
const publicPath = path.join(process.cwd(), 'public');
|
||||||
|
|
||||||
// Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
|
// Serve static files
|
||||||
// im Dateinamen – das Image ist also versioniert. Daher kann der Browser
|
app.use(express.static(publicPath));
|
||||||
// sie für ein Jahr aggressiv cachen und muss nicht revalidieren.
|
|
||||||
app.use(
|
|
||||||
'/assets',
|
|
||||||
express.static(path.join(publicPath, 'assets'), {
|
|
||||||
maxAge: '1y',
|
|
||||||
immutable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rest des Frontends (index.html selbst, vite.svg, robots.txt, sitemap.xml).
|
|
||||||
// express.static findet index.html bei GET /, deshalb MUSS hier das gleiche
|
|
||||||
// no-store-Verhalten greifen wie im SPA-Fallback weiter unten – sonst
|
|
||||||
// serviert der erste Static-Handler / mit dem express-Default `max-age=0`,
|
|
||||||
// bevor der Fallback überhaupt greift, und der Browser cached die alte SPA.
|
|
||||||
app.use(
|
|
||||||
express.static(publicPath, {
|
|
||||||
setHeaders: (res) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// SPA fallback: serve index.html for all non-API routes
|
// SPA fallback: serve index.html for all non-API routes
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
@@ -358,37 +104,16 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
if (req.path.startsWith('/api')) {
|
if (req.path.startsWith('/api')) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
// SPA-Wurzel darf NIE gecached werden – sonst sieht der Browser nach einem
|
|
||||||
// Deploy weiterhin die alte index.html mit alten Asset-Hashes.
|
|
||||||
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
|
||||||
res.sendFile(path.join(publicPath, 'index.html'));
|
res.sendFile(path.join(publicPath, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
|
|
||||||
// kaschiert und landen als "Interner Serverfehler" beim User.
|
|
||||||
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
|
||||||
let message = 'Interner Serverfehler';
|
|
||||||
if (status === 413) message = 'Anfrage zu groß';
|
|
||||||
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
|
|
||||||
message = 'Ungültiges JSON';
|
|
||||||
}
|
|
||||||
res.status(status).json({ success: false, error: message });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
|
app.listen(PORT, () => {
|
||||||
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
|
console.log(`Server läuft auf Port ${PORT}`);
|
||||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
|
||||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
|
||||||
startBirthdayScheduler();
|
|
||||||
startContractStatusScheduler();
|
|
||||||
startSecurityMonitorScheduler();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Response, NextFunction } from 'express';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
@@ -13,15 +12,12 @@ export async function authenticate(
|
|||||||
|
|
||||||
// Token aus Header oder Query-Parameter (für Downloads)
|
// Token aus Header oder Query-Parameter (für Downloads)
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
let tokenSource: 'header' | 'query' | null = null;
|
|
||||||
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
token = authHeader.split(' ')[1];
|
token = authHeader.split(' ')[1];
|
||||||
tokenSource = 'header';
|
|
||||||
} else if (req.query.token && typeof req.query.token === 'string') {
|
} else if (req.query.token && typeof req.query.token === 'string') {
|
||||||
// Fallback für Downloads: Token als Query-Parameter
|
// Fallback für Downloads: Token als Query-Parameter
|
||||||
token = req.query.token;
|
token = req.query.token;
|
||||||
tokenSource = 'query';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -30,49 +26,27 @@ export async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
|
const decoded = jwt.verify(
|
||||||
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
|
token,
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
|
process.env.JWT_SECRET || 'fallback-secret'
|
||||||
algorithms: ['HS256'],
|
) as JwtPayload;
|
||||||
}) as JwtPayload & { type?: string };
|
|
||||||
|
|
||||||
// Defense-in-Depth: Refresh-Tokens haben `type: 'refresh'` und dürfen
|
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
|
||||||
// NICHT für normale API-Calls verwendet werden – nur am /api/auth/refresh-
|
|
||||||
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
|
|
||||||
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
|
|
||||||
// zwangsabgemeldet werden.
|
|
||||||
if (decoded.type === 'refresh') {
|
|
||||||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
|
|
||||||
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
|
|
||||||
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
|
|
||||||
// werden (Pentest Runde 7 – NIEDRIG, Token-in-URL-Defense).
|
|
||||||
if (decoded.type === 'download' && tokenSource !== 'query') {
|
|
||||||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
|
|
||||||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
|
|
||||||
if (decoded.userId && decoded.iat) {
|
if (decoded.userId && decoded.iat) {
|
||||||
// Mitarbeiter-Login
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: { tokenInvalidatedAt: true, isActive: true },
|
select: { tokenInvalidatedAt: true, isActive: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Benutzer nicht gefunden oder deaktiviert
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token wurde vor der Invalidierung ausgestellt
|
||||||
if (user.tokenInvalidatedAt) {
|
if (user.tokenInvalidatedAt) {
|
||||||
const tokenIssuedAt = decoded.iat * 1000;
|
const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden
|
||||||
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -81,42 +55,11 @@ export async function authenticate(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) {
|
|
||||||
// Portal-Kunden-Login: gleiche Prüfung
|
|
||||||
const customer = await prisma.customer.findUnique({
|
|
||||||
where: { id: decoded.customerId },
|
|
||||||
select: { portalTokenInvalidatedAt: true, portalEnabled: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!customer || !customer.portalEnabled) {
|
|
||||||
res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customer.portalTokenInvalidatedAt) {
|
|
||||||
const tokenIssuedAt = decoded.iat * 1000;
|
|
||||||
if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) {
|
|
||||||
res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = decoded;
|
req.user = decoded;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch {
|
||||||
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
|
|
||||||
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'TOKEN_REJECTED',
|
|
||||||
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
|
|
||||||
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
|
|
||||||
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
|
|
||||||
endpoint: `${req.method} ${req.path}`,
|
|
||||||
});
|
|
||||||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
|
|
||||||
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
|
|
||||||
*
|
|
||||||
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
|
|
||||||
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
|
||||||
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
|
||||||
*/
|
|
||||||
import rateLimit from 'express-rate-limit';
|
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
|
||||||
|
|
||||||
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
|
||||||
return (req: any, _res: any) => {
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'RATE_LIMIT_HIT',
|
|
||||||
severity,
|
|
||||||
message: `Rate-Limit überschritten: ${label}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userEmail: req.body?.email,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
details: { limiter: label },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login: 10 Versuche pro 15 Minuten pro IP.
|
|
||||||
* Nach Überschreitung: 15 Min Sperre für diese IP.
|
|
||||||
*/
|
|
||||||
export const loginRateLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 Minuten
|
|
||||||
limit: 10, // Max. 10 Versuche pro Zeitfenster
|
|
||||||
standardHeaders: 'draft-7',
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.',
|
|
||||||
},
|
|
||||||
// Erfolgreiche Logins zählen nicht gegen das Limit
|
|
||||||
skipSuccessfulRequests: true,
|
|
||||||
handler: (req, res, _next, options) => {
|
|
||||||
onLimitReached('login', 'HIGH')(req, res);
|
|
||||||
res.status(options.statusCode).json(options.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP.
|
|
||||||
* Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links.
|
|
||||||
*/
|
|
||||||
export const passwordResetRateLimiter = rateLimit({
|
|
||||||
windowMs: 60 * 60 * 1000, // 1 Stunde
|
|
||||||
limit: 5,
|
|
||||||
standardHeaders: 'draft-7',
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
|
|
||||||
},
|
|
||||||
handler: (req, res, _next, options) => {
|
|
||||||
onLimitReached('password-reset', 'MEDIUM')(req, res);
|
|
||||||
res.status(options.statusCode).json(options.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import { Router } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import * as appSettingController from '../controllers/appSetting.controller.js';
|
import * as appSettingController from '../controllers/appSetting.controller.js';
|
||||||
import * as backupController from '../controllers/backup.controller.js';
|
import * as backupController from '../controllers/backup.controller.js';
|
||||||
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
|
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
// Multer für Backup-Upload (in Memory speichern)
|
// Multer für Backup-Upload (in Memory speichern)
|
||||||
@@ -101,18 +100,4 @@ router.post(
|
|||||||
backupController.factoryReset
|
backupController.factoryReset
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate-Limit-Verwaltung (Admin)
|
|
||||||
router.get(
|
|
||||||
'/rate-limits/active',
|
|
||||||
authenticate,
|
|
||||||
requirePermission('settings:read'),
|
|
||||||
rateLimitAdminController.getActiveRateLimits,
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
'/rate-limits/reset',
|
|
||||||
authenticate,
|
|
||||||
requirePermission('settings:update'),
|
|
||||||
rateLimitAdminController.resetRateLimit,
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as authController from '../controllers/auth.controller.js';
|
import * as authController from '../controllers/auth.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/login', loginRateLimiter, authController.login);
|
router.post('/login', authController.login);
|
||||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
|
||||||
router.post('/refresh', authController.refresh);
|
|
||||||
router.get('/me', authenticate, authController.me);
|
router.get('/me', authenticate, authController.me);
|
||||||
router.post('/logout', authenticate, authController.logout);
|
|
||||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
||||||
|
|
||||||
// Passwort-Reset-Flow
|
|
||||||
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
|
|
||||||
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
|
|
||||||
|
|
||||||
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
|
|
||||||
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
|
|
||||||
|
|
||||||
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
|
|
||||||
router.post('/download-token', authenticate, authController.createDownloadToken);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
|
|||||||
// Follow-up contract
|
// Follow-up contract
|
||||||
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
|
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
|
||||||
|
|
||||||
// VVL (Vertragsverlängerung beim selben Anbieter, vollständige Kopie + Datums-Berechnung)
|
|
||||||
router.post('/:id/renewal', authenticate, requirePermission('contracts:create'), contractController.createRenewal);
|
|
||||||
|
|
||||||
// Snooze (Vertrag zurückstellen)
|
// Snooze (Vertrag zurückstellen)
|
||||||
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
|
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
|
|||||||
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
|
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
|
||||||
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
|
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
|
||||||
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
|
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
|
||||||
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
|
|
||||||
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
|
|
||||||
|
|
||||||
// Representatives (Vertreter)
|
// Representatives (Vertreter)
|
||||||
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
|
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
|
||||||
|
|||||||
@@ -1,15 +1,54 @@
|
|||||||
import { Router, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
|
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu
|
||||||
// 2026-05-16 – KRITISCH). Er war ohne Auth erreichbar und konnte
|
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird
|
||||||
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
|
router.post('/setup', async (req, res: Response) => {
|
||||||
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
|
try {
|
||||||
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
|
// Create or get the developer:access permission
|
||||||
|
const developerPerm = await prisma.permission.upsert({
|
||||||
|
where: { resource_action: { resource: 'developer', action: 'access' } },
|
||||||
|
update: {},
|
||||||
|
create: { resource: 'developer', action: 'access' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the Admin role
|
||||||
|
const adminRole = await prisma.role.findUnique({
|
||||||
|
where: { name: 'Admin' },
|
||||||
|
include: { permissions: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
res.status(404).json({ success: false, error: 'Admin-Rolle nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Admin already has this permission
|
||||||
|
const hasPermission = adminRole.permissions.some(
|
||||||
|
(rp) => rp.permissionId === developerPerm.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
await prisma.rolePermission.create({
|
||||||
|
data: {
|
||||||
|
roleId: adminRole.id,
|
||||||
|
permissionId: developerPerm.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ success: true, message: 'developer:access Permission wurde zur Admin-Rolle hinzugefügt. Bitte neu einloggen!' });
|
||||||
|
} else {
|
||||||
|
res.json({ success: true, message: 'Admin-Rolle hat bereits die developer:access Permission' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Setup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Tabellen-Metadaten mit Beziehungen
|
// Tabellen-Metadaten mit Beziehungen
|
||||||
const tableMetadata: Record<string, {
|
const tableMetadata: Record<string, {
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
|
||||||
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// In-Memory-Upload für die ZIP – wird direkt verarbeitet, keine temporäre Datei.
|
|
||||||
const upload = multer({
|
|
||||||
storage: multer.memoryStorage(),
|
|
||||||
fileFilter: (_req, file, cb) => {
|
|
||||||
const ok =
|
|
||||||
file.mimetype === 'application/zip' ||
|
|
||||||
file.mimetype === 'application/x-zip-compressed' ||
|
|
||||||
file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip
|
|
||||||
file.originalname.toLowerCase().endsWith('.zip');
|
|
||||||
if (ok) cb(null, true);
|
|
||||||
else cb(new Error('Nur ZIP-Dateien sind erlaubt'));
|
|
||||||
},
|
|
||||||
limits: { fileSize: 50 * 1024 * 1024 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Preview (was wäre im Export drin?)
|
// Preview (was wäre im Export drin?)
|
||||||
router.get(
|
router.get(
|
||||||
'/preview',
|
'/preview',
|
||||||
@@ -36,13 +20,4 @@ router.get(
|
|||||||
factoryDefaultsController.exportFactoryDefaults,
|
factoryDefaultsController.exportFactoryDefaults,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Import aus ZIP (multipart, Feld 'zip')
|
|
||||||
router.post(
|
|
||||||
'/import',
|
|
||||||
authenticate,
|
|
||||||
requirePermission('settings:update'),
|
|
||||||
upload.single('zip'),
|
|
||||||
factoryDefaultsController.importFactoryDefaults,
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
|
||||||
import * as monitoringController from '../controllers/monitoring.controller.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
|
|
||||||
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
|
|
||||||
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
|
|
||||||
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
|
|
||||||
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
|
|
||||||
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
|
|
||||||
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
|
// Provider routes
|
||||||
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
|
router.get('/', authenticate, providerController.getProviders);
|
||||||
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
|
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
|
||||||
router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider);
|
router.get('/:id', authenticate, providerController.getProvider);
|
||||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||||
|
|
||||||
// Nested tariff routes
|
// Nested tariff routes
|
||||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs);
|
||||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -12,7 +12,4 @@ router.delete('/:id', authenticate, requirePermission('customers:delete'), stres
|
|||||||
// Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider)
|
// Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider)
|
||||||
router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword);
|
router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword);
|
||||||
|
|
||||||
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
|
||||||
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Standalone tariff routes (for update/delete by tariff id)
|
// Standalone tariff routes (for update/delete by tariff id)
|
||||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
router.get('/:id', authenticate, tariffController.getTariff);
|
||||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import prisma from '../lib/prisma.js';
|
|||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -547,7 +546,6 @@ async function handleContractDocumentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
|
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -565,51 +563,12 @@ async function handleContractDocumentUpload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
|
|
||||||
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
|
|
||||||
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
|
|
||||||
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
|
|
||||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
|
||||||
? 'cancellationConfirmationDate'
|
|
||||||
: 'cancellationConfirmationOptionsDate';
|
|
||||||
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
|
|
||||||
let target: Date | null = null;
|
|
||||||
if (provided) {
|
|
||||||
const parsed = new Date(provided);
|
|
||||||
if (!isNaN(parsed.getTime())) target = parsed;
|
|
||||||
}
|
|
||||||
if (target) {
|
|
||||||
updateData[dateField] = target;
|
|
||||||
} else if (!contract[dateField]) {
|
|
||||||
updateData[dateField] = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertrag in der DB aktualisieren
|
// Vertrag in der DB aktualisieren
|
||||||
await prisma.contract.update({
|
await prisma.contract.update({
|
||||||
where: { id: contractId },
|
where: { id: contractId },
|
||||||
data: updateData,
|
data: { [fieldName]: relativePath },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
|
|
||||||
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
|
|
||||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
|
||||||
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
|
|
||||||
await prisma.contract.update({
|
|
||||||
where: { id: contractId },
|
|
||||||
data: { status: 'CANCELLED' },
|
|
||||||
});
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'Contract',
|
|
||||||
resourceId: contractId.toString(),
|
|
||||||
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
|
|
||||||
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
|
|
||||||
customerId: contract.customerId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -633,7 +592,6 @@ async function handleContractDocumentDelete(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
|
|||||||
@@ -112,13 +112,6 @@ function determineSensitivity(resourceType: string): AuditSensitivity {
|
|||||||
Authentication: 'CRITICAL',
|
Authentication: 'CRITICAL',
|
||||||
BankCard: 'CRITICAL',
|
BankCard: 'CRITICAL',
|
||||||
IdentityDocument: 'CRITICAL',
|
IdentityDocument: 'CRITICAL',
|
||||||
// Klartext-Passwort-Reads – jeder Decrypt-Vorgang muss nachvollziehbar sein
|
|
||||||
PortalPassword: 'CRITICAL',
|
|
||||||
ContractPassword: 'CRITICAL',
|
|
||||||
SimCardCredentials: 'CRITICAL',
|
|
||||||
InternetCredentials: 'CRITICAL',
|
|
||||||
SipCredentials: 'CRITICAL',
|
|
||||||
MailboxCredentials: 'CRITICAL',
|
|
||||||
// HIGH
|
// HIGH
|
||||||
Customer: 'HIGH',
|
Customer: 'HIGH',
|
||||||
User: 'HIGH',
|
User: 'HIGH',
|
||||||
|
|||||||
@@ -1,81 +1,8 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
|
||||||
import { JwtPayload } from '../types/index.js';
|
import { JwtPayload } from '../types/index.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
|
||||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
|
||||||
import { getAuthorizedCustomerIds } from './authorization.service.js';
|
|
||||||
|
|
||||||
// Token-Lifetimes
|
|
||||||
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
|
|
||||||
// - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff
|
|
||||||
const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn'];
|
|
||||||
const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'];
|
|
||||||
|
|
||||||
// Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als
|
|
||||||
// Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim
|
|
||||||
// /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt.
|
|
||||||
export function signAccessToken(payload: JwtPayload): string {
|
|
||||||
return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, {
|
|
||||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
export function signRefreshToken(payload: JwtPayload): string {
|
|
||||||
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
|
|
||||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
|
||||||
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
|
|
||||||
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
|
|
||||||
// nginx-Access-Logs oder der Browser-History landet, ist er nach
|
|
||||||
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) – NIEDRIG.
|
|
||||||
export function signDownloadToken(payload: JwtPayload): string {
|
|
||||||
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
|
|
||||||
expiresIn: '60s',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
|
|
||||||
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
|
|
||||||
const BCRYPT_COST = 12;
|
|
||||||
|
|
||||||
// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
|
|
||||||
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
|
|
||||||
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
|
|
||||||
// dem Timing-Angleich.
|
|
||||||
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
|
|
||||||
* Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
|
|
||||||
* aus der Installation) werden so lazy auf Cost 12 migriert – damit sich die
|
|
||||||
* Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
|
|
||||||
*/
|
|
||||||
async function maybeUpgradePasswordHash(
|
|
||||||
table: 'user' | 'customer',
|
|
||||||
id: number,
|
|
||||||
plaintextPassword: string,
|
|
||||||
currentHash: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
|
|
||||||
const currentCost = match ? parseInt(match[1], 10) : 0;
|
|
||||||
if (currentCost === BCRYPT_COST) return;
|
|
||||||
try {
|
|
||||||
const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
|
|
||||||
if (table === 'user') {
|
|
||||||
await prisma.user.update({ where: { id }, data: { password: newHash } });
|
|
||||||
} else {
|
|
||||||
await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Nicht kritisch – Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
|
|
||||||
console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mitarbeiter-Login
|
// Mitarbeiter-Login
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
@@ -99,9 +26,6 @@ export async function login(email: string, password: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
// Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
|
|
||||||
// nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
|
|
||||||
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
|
|
||||||
throw new Error('Ungültige Anmeldedaten');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +34,6 @@ export async function login(email: string, password: string) {
|
|||||||
throw new Error('Ungültige Anmeldedaten');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
|
|
||||||
// Async, nicht blockierend für die Response.
|
|
||||||
maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});
|
|
||||||
|
|
||||||
// Collect all permissions from all roles
|
// Collect all permissions from all roles
|
||||||
const permissions = new Set<string>();
|
const permissions = new Set<string>();
|
||||||
for (const userRole of user.roles) {
|
for (const userRole of user.roles) {
|
||||||
@@ -132,12 +52,12 @@ export async function login(email: string, password: string) {
|
|||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = signAccessToken(payload);
|
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
|
||||||
const refreshToken = signRefreshToken(payload);
|
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
token,
|
||||||
refreshToken,
|
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -180,8 +100,6 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
|
|
||||||
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
|
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
|
||||||
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
|
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
|
||||||
// Timing-Attack-Schutz (siehe login())
|
|
||||||
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
|
|
||||||
throw new Error('Ungültige Anmeldedaten');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,42 +110,16 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
throw new Error('Ungültige Anmeldedaten');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
|
// Letzte Anmeldung aktualisieren
|
||||||
// Falls ja, jetzt sofort verbrauchen – Hash + Encrypted nullen, damit
|
await prisma.customer.update({
|
||||||
// weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
|
where: { id: customer.id },
|
||||||
// Force-Change-Password-Flow.
|
data: { portalLastLogin: new Date() },
|
||||||
const mustChangePassword = customer.portalPasswordMustChange === true;
|
});
|
||||||
if (mustChangePassword) {
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: customer.id },
|
|
||||||
data: {
|
|
||||||
portalPasswordHash: null,
|
|
||||||
portalPasswordEncrypted: null,
|
|
||||||
portalLastLogin: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Lazy-Upgrade analog zu Mitarbeiter-Login
|
|
||||||
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
|
|
||||||
|
|
||||||
// Letzte Anmeldung aktualisieren
|
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
|
||||||
await prisma.customer.update({
|
const representedCustomerIds = customer.representingFor.map(
|
||||||
where: { id: customer.id },
|
(rep) => rep.customer.id
|
||||||
data: { portalLastLogin: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// IDs der Kunden sammeln, die dieser Kunde vertreten kann –
|
|
||||||
// GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
|
|
||||||
// hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte
|
|
||||||
// representedCustomerIds-Liste; die UI würde dem Vertreter noch
|
|
||||||
// anzeigen, dass er vertreten kann, obwohl der Live-Check beim
|
|
||||||
// Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM.
|
|
||||||
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
|
|
||||||
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
|
|
||||||
grantedCustomerIds.has(rep.customer.id),
|
|
||||||
);
|
);
|
||||||
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
|
||||||
|
|
||||||
// Kundenportal-Berechtigungen (eingeschränkt)
|
// Kundenportal-Berechtigungen (eingeschränkt)
|
||||||
const customerPermissions = [
|
const customerPermissions = [
|
||||||
@@ -243,13 +135,12 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
representedCustomerIds,
|
representedCustomerIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = signAccessToken(payload);
|
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
|
||||||
const refreshToken = signRefreshToken(payload);
|
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
token,
|
||||||
refreshToken,
|
|
||||||
mustChangePassword,
|
|
||||||
user: {
|
user: {
|
||||||
id: customer.id,
|
id: customer.id,
|
||||||
email: customer.portalEmail,
|
email: customer.portalEmail,
|
||||||
@@ -258,8 +149,7 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
permissions: customerPermissions,
|
permissions: customerPermissions,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
isCustomerPortal: true,
|
isCustomerPortal: true,
|
||||||
mustChangePassword,
|
representedCustomers: customer.representingFor.map((rep) => ({
|
||||||
representedCustomers: grantedRepresentingFor.map((rep) => ({
|
|
||||||
id: rep.customer.id,
|
id: rep.customer.id,
|
||||||
customerNumber: rep.customer.customerNumber,
|
customerNumber: rep.customer.customerNumber,
|
||||||
firstName: rep.customer.firstName,
|
firstName: rep.customer.firstName,
|
||||||
@@ -271,142 +161,26 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei
|
|
||||||
// ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt
|
|
||||||
// vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch
|
|
||||||
// das Refresh) sofort tot.
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<{
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: any;
|
|
||||||
}> {
|
|
||||||
let decoded: any;
|
|
||||||
try {
|
|
||||||
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
|
||||||
algorithms: ['HS256'],
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new Error('Refresh-Token ungültig oder abgelaufen');
|
|
||||||
}
|
|
||||||
if (decoded.type !== 'refresh') {
|
|
||||||
throw new Error('Falscher Token-Typ');
|
|
||||||
}
|
|
||||||
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
|
|
||||||
|
|
||||||
// Mitarbeiter
|
|
||||||
if (!decoded.isCustomerPortal && decoded.userId) {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: decoded.userId },
|
|
||||||
include: {
|
|
||||||
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv');
|
|
||||||
if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) {
|
|
||||||
throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
|
||||||
}
|
|
||||||
const permissions = new Set<string>();
|
|
||||||
for (const ur of user.roles) {
|
|
||||||
for (const rp of ur.role.permissions) {
|
|
||||||
permissions.add(`${rp.permission.resource}:${rp.permission.action}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const payload: JwtPayload = {
|
|
||||||
userId: user.id,
|
|
||||||
email: user.email,
|
|
||||||
permissions: Array.from(permissions),
|
|
||||||
customerId: user.customerId ?? undefined,
|
|
||||||
isCustomerPortal: false,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
accessToken: signAccessToken(payload),
|
|
||||||
refreshToken: signRefreshToken(payload),
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
permissions: Array.from(permissions),
|
|
||||||
customerId: user.customerId,
|
|
||||||
isCustomerPortal: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customer-Portal
|
|
||||||
if (decoded.isCustomerPortal && decoded.customerId) {
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } });
|
|
||||||
if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden');
|
|
||||||
if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) {
|
|
||||||
throw new Error('Refresh-Token wurde invalidiert');
|
|
||||||
}
|
|
||||||
const portalUser = await getCustomerPortalUser(customer.id);
|
|
||||||
if (!portalUser) throw new Error('Portal-Konto nicht gefunden');
|
|
||||||
const payload: JwtPayload = {
|
|
||||||
email: customer.portalEmail,
|
|
||||||
permissions: portalUser.permissions,
|
|
||||||
customerId: customer.id,
|
|
||||||
isCustomerPortal: true,
|
|
||||||
representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
accessToken: signAccessToken(payload),
|
|
||||||
refreshToken: signRefreshToken(payload),
|
|
||||||
user: portalUser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Refresh-Token konnte nicht interpretiert werden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kundenportal-Passwort setzen/ändern
|
// Kundenportal-Passwort setzen/ändern
|
||||||
export async function setCustomerPortalPassword(customerId: number, password: string) {
|
export async function setCustomerPortalPassword(customerId: number, password: string) {
|
||||||
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
|
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
const encryptedPassword = encrypt(password);
|
const encryptedPassword = encrypt(password);
|
||||||
|
|
||||||
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
|
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
|
||||||
|
|
||||||
// Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
|
|
||||||
// falls vorher ein OTP gesetzt war.
|
|
||||||
await prisma.customer.update({
|
await prisma.customer.update({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
data: {
|
data: {
|
||||||
portalPasswordHash: hashedPassword,
|
portalPasswordHash: hashedPassword,
|
||||||
portalPasswordEncrypted: encryptedPassword,
|
portalPasswordEncrypted: encryptedPassword,
|
||||||
portalPasswordMustChange: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[SetPortalPassword] Passwort gespeichert');
|
console.log('[SetPortalPassword] Passwort gespeichert');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
|
|
||||||
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
|
|
||||||
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
|
|
||||||
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
|
|
||||||
// gefordert wird.
|
|
||||||
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
|
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: customerId },
|
|
||||||
data: {
|
|
||||||
portalPasswordHash: hashedPassword,
|
|
||||||
portalPasswordEncrypted: null,
|
|
||||||
portalPasswordMustChange: false,
|
|
||||||
portalTokenInvalidatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markPortalPasswordForChange(customerId: number) {
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: customerId },
|
|
||||||
data: { portalPasswordMustChange: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kundenportal-Passwort im Klartext abrufen
|
// Kundenportal-Passwort im Klartext abrufen
|
||||||
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
|
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
@@ -434,7 +208,7 @@ export async function createUser(data: {
|
|||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
}) {
|
}) {
|
||||||
const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -546,13 +320,6 @@ export async function getCustomerPortalUser(customerId: number) {
|
|||||||
'customers:read',
|
'customers:read',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
|
||||||
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
|
||||||
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
|
|
||||||
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
|
|
||||||
grantedCustomerIds.has(rep.customer.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: customer.id,
|
id: customer.id,
|
||||||
email: customer.portalEmail,
|
email: customer.portalEmail,
|
||||||
@@ -562,7 +329,7 @@ export async function getCustomerPortalUser(customerId: number) {
|
|||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
permissions: customerPermissions,
|
permissions: customerPermissions,
|
||||||
isCustomerPortal: true,
|
isCustomerPortal: true,
|
||||||
representedCustomers: grantedRepresentingFor.map((rep) => ({
|
representedCustomers: customer.representingFor.map((rep) => ({
|
||||||
id: rep.customer.id,
|
id: rep.customer.id,
|
||||||
customerNumber: rep.customer.customerNumber,
|
customerNumber: rep.customer.customerNumber,
|
||||||
firstName: rep.customer.firstName,
|
firstName: rep.customer.firstName,
|
||||||
@@ -572,258 +339,3 @@ export async function getCustomerPortalUser(customerId: number) {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PASSWORT-RESET ====================
|
|
||||||
|
|
||||||
const RESET_TOKEN_EXPIRY_HOURS = 2;
|
|
||||||
|
|
||||||
function generateResetToken(): string {
|
|
||||||
return crypto.randomBytes(32).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPublicUrl(): string {
|
|
||||||
return process.env.PUBLIC_URL || 'http://localhost:5173';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
|
|
||||||
* UI ausgelöst – nie automatisch –, weil das Klartext-Passwort im Mail-
|
|
||||||
* Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
|
|
||||||
*/
|
|
||||||
export async function sendPortalCredentialsEmail(params: {
|
|
||||||
to: string;
|
|
||||||
customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
|
|
||||||
loginEmail: string;
|
|
||||||
password: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const systemEmail = await getSystemEmailCredentials();
|
|
||||||
if (!systemEmail) {
|
|
||||||
throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials: SmtpCredentials = {
|
|
||||||
host: systemEmail.smtpServer,
|
|
||||||
port: systemEmail.smtpPort,
|
|
||||||
user: systemEmail.emailAddress,
|
|
||||||
password: systemEmail.password,
|
|
||||||
encryption: systemEmail.smtpEncryption,
|
|
||||||
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginUrl = `${getPublicUrl()}/portal/login`;
|
|
||||||
const name = params.customer.companyName?.trim()
|
|
||||||
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|
|
||||||
|| 'Kunde';
|
|
||||||
|
|
||||||
// HTML-Escape – Customer-Namen können theoretisch Sonderzeichen enthalten,
|
|
||||||
// die wir nicht ungefiltert in die Mail rendern wollen.
|
|
||||||
const esc = (s: string) =>
|
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
||||||
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
|
|
||||||
<p>Hallo ${esc(name)},</p>
|
|
||||||
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
|
|
||||||
<table style="border-collapse: collapse; margin: 16px 0;">
|
|
||||||
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
|
|
||||||
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
|
|
||||||
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
|
|
||||||
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
|
|
||||||
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
|
|
||||||
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
|
|
||||||
</table>
|
|
||||||
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
|
|
||||||
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
|
|
||||||
</p>
|
|
||||||
<p style="color: #6b7280; font-size: 14px;">
|
|
||||||
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
|
|
||||||
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> – falls Sie den
|
|
||||||
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
|
|
||||||
Passwort-vergessen-Funktion.
|
|
||||||
</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
|
||||||
<p style="color: #9ca3af; font-size: 12px;">
|
|
||||||
Diese Nachricht enthält sensible Zugangsdaten – bitte sicher verwahren oder nach
|
|
||||||
dem Login löschen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sendEmail(
|
|
||||||
credentials,
|
|
||||||
systemEmail.emailAddress,
|
|
||||||
{
|
|
||||||
to: params.to,
|
|
||||||
subject: 'Ihre Zugangsdaten zum Kundenportal',
|
|
||||||
html,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: 'portal-credentials',
|
|
||||||
triggeredBy: 'admin-action',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passwort-Reset-Link per Email senden.
|
|
||||||
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
|
|
||||||
* (Schutz vor User-Enumeration – Caller gibt immer success zurück).
|
|
||||||
*/
|
|
||||||
export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise<void> {
|
|
||||||
const token = generateResetToken();
|
|
||||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
let recipient: { email: string; firstName: string; lastName: string } | null = null;
|
|
||||||
|
|
||||||
if (userType === 'admin') {
|
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
|
||||||
if (!user || !user.isActive) return;
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
passwordResetToken: token,
|
|
||||||
passwordResetExpiresAt: expiresAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName };
|
|
||||||
} else {
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { portalEmail: email } });
|
|
||||||
if (!customer || !customer.portalEnabled) return;
|
|
||||||
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: customer.id },
|
|
||||||
data: {
|
|
||||||
portalPasswordResetToken: token,
|
|
||||||
portalPasswordResetExpiresAt: expiresAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
recipient = {
|
|
||||||
email: customer.portalEmail!,
|
|
||||||
firstName: customer.firstName,
|
|
||||||
lastName: customer.lastName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recipient) return;
|
|
||||||
|
|
||||||
// Reset-Link + Email senden
|
|
||||||
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
|
|
||||||
const systemEmail = await getSystemEmailCredentials();
|
|
||||||
|
|
||||||
if (!systemEmail) {
|
|
||||||
console.warn(
|
|
||||||
`[passwordReset] Kein System-E-Mail konfiguriert – Reset-Link für ${recipient.email}: ${resetUrl}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials: SmtpCredentials = {
|
|
||||||
host: systemEmail.smtpServer,
|
|
||||||
port: systemEmail.smtpPort,
|
|
||||||
user: systemEmail.emailAddress,
|
|
||||||
password: systemEmail.password,
|
|
||||||
encryption: systemEmail.smtpEncryption,
|
|
||||||
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
|
||||||
};
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
||||||
<h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
|
|
||||||
<p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
|
|
||||||
<p>
|
|
||||||
Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden
|
|
||||||
Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig.
|
|
||||||
</p>
|
|
||||||
<p style="text-align: center; margin: 32px 0;">
|
|
||||||
<a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
|
|
||||||
Neues Passwort vergeben
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="color: #6b7280; font-size: 14px;">
|
|
||||||
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
|
|
||||||
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
|
|
||||||
</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
|
||||||
<p style="color: #9ca3af; font-size: 12px;">
|
|
||||||
Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach –
|
|
||||||
Ihr Passwort bleibt unverändert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sendEmail(
|
|
||||||
credentials,
|
|
||||||
systemEmail.emailAddress,
|
|
||||||
{
|
|
||||||
to: recipient.email,
|
|
||||||
subject: 'Passwort zurücksetzen',
|
|
||||||
html,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: 'password-reset',
|
|
||||||
triggeredBy: 'self-service',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
|
|
||||||
* Invalidiert alle bestehenden JWT-Sessions des Users.
|
|
||||||
*/
|
|
||||||
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
|
|
||||||
// Erst beim User suchen
|
|
||||||
const user = await prisma.user.findUnique({ where: { passwordResetToken: token } });
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) {
|
|
||||||
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
password: hash,
|
|
||||||
passwordResetToken: null,
|
|
||||||
passwordResetExpiresAt: null,
|
|
||||||
// Alle bestehenden Sessions kicken
|
|
||||||
tokenInvalidatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sonst beim Customer (Portal)
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } });
|
|
||||||
|
|
||||||
if (customer) {
|
|
||||||
if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) {
|
|
||||||
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: customer.id },
|
|
||||||
data: {
|
|
||||||
portalPasswordHash: hash,
|
|
||||||
// Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir
|
|
||||||
// KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte
|
|
||||||
// Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per
|
|
||||||
// Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort
|
|
||||||
// ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak.
|
|
||||||
portalPasswordEncrypted: null,
|
|
||||||
portalPasswordResetToken: null,
|
|
||||||
portalPasswordResetExpiresAt: null,
|
|
||||||
// Alle bestehenden Portal-Sessions kicken
|
|
||||||
portalTokenInvalidatedAt: new Date(),
|
|
||||||
// OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
|
|
||||||
portalPasswordMustChange: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Ungültiger oder bereits verwendeter Link.');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ export async function createBackup(): Promise<BackupResult> {
|
|||||||
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
||||||
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
||||||
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
|
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
|
||||||
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalRecords = 0;
|
let totalRecords = 0;
|
||||||
@@ -311,7 +310,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
// Logs & Audit zuerst (hängen an allem)
|
// Logs & Audit zuerst (hängen an allem)
|
||||||
await prisma.auditLog.deleteMany({});
|
await prisma.auditLog.deleteMany({});
|
||||||
await prisma.emailLog.deleteMany({});
|
await prisma.emailLog.deleteMany({});
|
||||||
await prisma.securityEvent.deleteMany({});
|
|
||||||
|
|
||||||
// Detail-Tabellen
|
// Detail-Tabellen
|
||||||
await prisma.carInsuranceDetails.deleteMany({});
|
await prisma.carInsuranceDetails.deleteMany({});
|
||||||
@@ -889,18 +887,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'SecurityEvent',
|
|
||||||
restore: async (data: any[]) => {
|
|
||||||
for (const item of data) {
|
|
||||||
await prisma.securityEvent.upsert({
|
|
||||||
where: { id: item.id },
|
|
||||||
update: convertDates(item),
|
|
||||||
create: convertDates(item),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalRestored = 0;
|
let totalRestored = 0;
|
||||||
@@ -1021,36 +1007,8 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
|
|
||||||
const finalBackupName = path.basename(finalBackupDir);
|
const finalBackupName = path.basename(finalBackupDir);
|
||||||
|
|
||||||
// ZIP entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
// ZIP extrahieren
|
||||||
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
|
zip.extractAllTo(finalBackupDir, true);
|
||||||
const absBackupDir = path.resolve(finalBackupDir);
|
|
||||||
fs.mkdirSync(absBackupDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
// Pfade mit absoluten Pfaden oder Traversal ablehnen
|
|
||||||
const entryName = entry.entryName;
|
|
||||||
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
|
|
||||||
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = path.resolve(absBackupDir, entryName);
|
|
||||||
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
|
|
||||||
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
|
||||||
fs.mkdirSync(targetPath, { recursive: true });
|
|
||||||
} else {
|
|
||||||
// Zielverzeichnis sicherstellen
|
|
||||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
||||||
// Datei schreiben
|
|
||||||
fs.writeFileSync(targetPath, entry.getData());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, backupName: finalBackupName };
|
return { success: true, backupName: finalBackupName };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scheduler für automatische Geburtstagsgrüße.
|
|
||||||
*
|
|
||||||
* Läuft täglich um 08:00 Uhr und sendet Grüße an alle Kunden mit:
|
|
||||||
* - Geburtstag = heute
|
|
||||||
* - autoBirthdayGreeting = true
|
|
||||||
* - autoBirthdayChannel ist gesetzt (aktuell nur 'email' automatisiert)
|
|
||||||
* - lastBirthdayGreetingYear != aktuelles Jahr (verhindert Doppel-Versand)
|
|
||||||
*/
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
|
||||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
|
||||||
import * as birthdayService from './birthday.service.js';
|
|
||||||
|
|
||||||
async function runDailyBirthdayGreetings(): Promise<void> {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const thisYear = today.getFullYear();
|
|
||||||
const month = today.getMonth() + 1; // Prisma-Raw-SQL ist 1-indexed
|
|
||||||
const day = today.getDate();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[BirthdayScheduler] Suche Kunden mit Geburtstag ${day}.${month}., Auto-Versand aktiv …`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Kunden mit heutigem Geburtstag + Auto-Versand + dieses Jahr noch nicht gesendet
|
|
||||||
const candidates = await prisma.$queryRaw<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string | null;
|
|
||||||
salutation: string | null;
|
|
||||||
useInformalAddress: boolean;
|
|
||||||
birthDate: Date;
|
|
||||||
autoBirthdayChannel: string | null;
|
|
||||||
}>
|
|
||||||
>`
|
|
||||||
SELECT id, firstName, lastName, email, salutation, useInformalAddress, birthDate, autoBirthdayChannel
|
|
||||||
FROM Customer
|
|
||||||
WHERE autoBirthdayGreeting = 1
|
|
||||||
AND birthDate IS NOT NULL
|
|
||||||
AND MONTH(birthDate) = ${month}
|
|
||||||
AND DAY(birthDate) = ${day}
|
|
||||||
AND (lastBirthdayGreetingYear IS NULL OR lastBirthdayGreetingYear != ${thisYear})
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
console.log('[BirthdayScheduler] Keine passenden Kunden heute.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[BirthdayScheduler] ${candidates.length} Kunde(n) gefunden – sende Grüße.`);
|
|
||||||
|
|
||||||
// System-E-Mail-Credentials einmal laden
|
|
||||||
const systemEmail = await getSystemEmailCredentials();
|
|
||||||
if (!systemEmail) {
|
|
||||||
console.error(
|
|
||||||
'[BirthdayScheduler] Keine System-E-Mail konfiguriert – kann keine Grüße versenden.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const smtpCreds: SmtpCredentials = {
|
|
||||||
host: systemEmail.smtpServer,
|
|
||||||
port: systemEmail.smtpPort,
|
|
||||||
user: systemEmail.emailAddress,
|
|
||||||
password: systemEmail.password,
|
|
||||||
encryption: systemEmail.smtpEncryption,
|
|
||||||
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
|
||||||
};
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
for (const c of candidates) {
|
|
||||||
const channel = c.autoBirthdayChannel || 'email';
|
|
||||||
|
|
||||||
// Aktuell nur Email automatisch – Messenger brauchen Browser-Klick
|
|
||||||
if (channel !== 'email') {
|
|
||||||
console.log(
|
|
||||||
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): Kanal "${channel}" nicht automatisierbar, übersprungen.`,
|
|
||||||
);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!c.email) {
|
|
||||||
console.log(
|
|
||||||
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): keine E-Mail hinterlegt, übersprungen.`,
|
|
||||||
);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const age = thisYear - new Date(c.birthDate).getFullYear();
|
|
||||||
const { subject, html } = birthdayService.buildBirthdayGreetingText(
|
|
||||||
{
|
|
||||||
firstName: c.firstName,
|
|
||||||
lastName: c.lastName,
|
|
||||||
salutation: c.salutation,
|
|
||||||
useInformalAddress: c.useInformalAddress,
|
|
||||||
},
|
|
||||||
age,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await sendEmail(
|
|
||||||
smtpCreds,
|
|
||||||
systemEmail.emailAddress,
|
|
||||||
{ to: c.email, subject, html },
|
|
||||||
{ context: 'birthday-greeting-auto', customerId: c.id, triggeredBy: 'cron' },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Marker setzen damit nächstes Jahr wieder läuft, dieses Jahr aber nicht nochmal
|
|
||||||
await prisma.customer.update({
|
|
||||||
where: { id: c.id },
|
|
||||||
data: { lastBirthdayGreetingYear: thisYear },
|
|
||||||
});
|
|
||||||
sent++;
|
|
||||||
console.log(
|
|
||||||
`[BirthdayScheduler] ✓ Kunde #${c.id} (${c.firstName} ${c.lastName}): Gruß gesendet.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`[BirthdayScheduler] ✗ Kunde #${c.id}: Sendfehler: ${result.error}`,
|
|
||||||
);
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[BirthdayScheduler] ✗ Kunde #${c.id}: Exception:`, err);
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[BirthdayScheduler] Fertig: ${sent} versendet, ${skipped} übersprungen von ${candidates.length} Kandidaten.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduler starten. Läuft täglich um 08:00 in lokaler Server-Zeit.
|
|
||||||
* Zusätzlich: ein Test-Lauf 30 Sekunden nach Server-Start, aber nur wenn heute schon jemand Geburtstag hat
|
|
||||||
* (sonst passiert eh nichts). So können wir bei Ausfall am Tag X direkt beim nächsten Boot nachholen.
|
|
||||||
*/
|
|
||||||
export function startBirthdayScheduler(): void {
|
|
||||||
// Täglich um 08:00
|
|
||||||
cron.schedule('0 8 * * *', () => {
|
|
||||||
runDailyBirthdayGreetings().catch((err) =>
|
|
||||||
console.error('[BirthdayScheduler] Daily run failed:', err),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Einmal 30 Sekunden nach Start (Catch-up bei Ausfall)
|
|
||||||
setTimeout(() => {
|
|
||||||
runDailyBirthdayGreetings().catch((err) =>
|
|
||||||
console.error('[BirthdayScheduler] Catch-up run failed:', err),
|
|
||||||
);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
console.log('[BirthdayScheduler] Gestartet – täglich um 08:00 + Catch-up nach 30s');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Für manuelles Triggern (z.B. aus Debug-Endpoint).
|
|
||||||
*/
|
|
||||||
export { runDailyBirthdayGreetings };
|
|
||||||
@@ -49,18 +49,6 @@ export interface EmailListOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
includeBody?: boolean;
|
includeBody?: boolean;
|
||||||
// Suche / Filter (alle AND-verknüpft)
|
|
||||||
search?: string; // Volltextsuche über subject + from + body
|
|
||||||
fromFilter?: string; // Absender enthält
|
|
||||||
toFilter?: string; // Empfänger enthält
|
|
||||||
subjectFilter?: string; // Subject enthält
|
|
||||||
bodyFilter?: string; // Body enthält (text/html)
|
|
||||||
attachmentNameFilter?: string; // Anhang-Dateiname enthält
|
|
||||||
hasAttachments?: boolean; // Nur mit/ohne Anhang
|
|
||||||
isRead?: boolean; // Gelesen-Status
|
|
||||||
isStarred?: boolean; // Markiert-Status
|
|
||||||
receivedFrom?: Date; // Empfangen ab
|
|
||||||
receivedTo?: Date; // Empfangen bis
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SYNC FUNCTIONS ====================
|
// ==================== SYNC FUNCTIONS ====================
|
||||||
@@ -285,59 +273,6 @@ export async function getCachedEmails(
|
|||||||
where.folder = EmailFolder.INBOX;
|
where.folder = EmailFolder.INBOX;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Such-/Filter-Parameter =====
|
|
||||||
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
|
|
||||||
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
|
|
||||||
if (options.search && options.search.trim()) {
|
|
||||||
const q = options.search.trim();
|
|
||||||
where.OR = [
|
|
||||||
{ subject: { contains: q } },
|
|
||||||
{ fromAddress: { contains: q } },
|
|
||||||
{ fromName: { contains: q } },
|
|
||||||
{ textBody: { contains: q } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
|
|
||||||
if (options.fromFilter?.trim()) {
|
|
||||||
const q = options.fromFilter.trim();
|
|
||||||
// Treffer in fromAddress ODER fromName – für den Nutzer ist „Von" beides
|
|
||||||
where.AND = [
|
|
||||||
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
|
||||||
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (options.toFilter?.trim()) {
|
|
||||||
where.toAddresses = { contains: options.toFilter.trim() };
|
|
||||||
}
|
|
||||||
if (options.subjectFilter?.trim()) {
|
|
||||||
where.subject = { contains: options.subjectFilter.trim() };
|
|
||||||
}
|
|
||||||
if (options.bodyFilter?.trim()) {
|
|
||||||
const q = options.bodyFilter.trim();
|
|
||||||
where.AND = [
|
|
||||||
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
|
||||||
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (options.attachmentNameFilter?.trim()) {
|
|
||||||
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
|
|
||||||
}
|
|
||||||
if (typeof options.hasAttachments === 'boolean') {
|
|
||||||
where.hasAttachments = options.hasAttachments;
|
|
||||||
}
|
|
||||||
if (typeof options.isRead === 'boolean') {
|
|
||||||
where.isRead = options.isRead;
|
|
||||||
}
|
|
||||||
if (typeof options.isStarred === 'boolean') {
|
|
||||||
where.isStarred = options.isStarred;
|
|
||||||
}
|
|
||||||
if (options.receivedFrom || options.receivedTo) {
|
|
||||||
where.receivedAt = {};
|
|
||||||
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
|
|
||||||
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
|
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
|
||||||
const select: Prisma.CachedEmailSelect = {
|
const select: Prisma.CachedEmailSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ContractType, ContractStatus } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
|
|
||||||
|
|
||||||
export interface ContractFilters {
|
export interface ContractFilters {
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
@@ -155,18 +154,7 @@ export async function getContractById(id: number, decryptPassword = false) {
|
|||||||
|
|
||||||
if (!contract) return null;
|
if (!contract) return null;
|
||||||
|
|
||||||
// SECURITY: Embedded Customer-Objekt sanitizen, sonst leaken
|
// Decrypt password if requested and exists
|
||||||
// portalPasswordHash + portalPasswordEncrypted + Reset-Token in jede
|
|
||||||
// contract.customer-Response. Der direkte `/customers/:id`-Endpoint hat
|
|
||||||
// den Schutz schon; hier wäre er ohne Sanitize bypassbar.
|
|
||||||
if (contract.customer) {
|
|
||||||
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
|
|
||||||
contract.customer as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt password if requested and exists (Contract-Anbieter-Passwort,
|
|
||||||
// nicht zu verwechseln mit Customer-Portal-Passwort)
|
|
||||||
if (decryptPassword && contract.portalPasswordEncrypted) {
|
if (decryptPassword && contract.portalPasswordEncrypted) {
|
||||||
try {
|
try {
|
||||||
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
|
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
|
||||||
@@ -397,15 +385,6 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Embedded Customer-Objekt sanitizen (siehe getContractById – derselbe
|
|
||||||
// Schutz; createContract gibt den frisch erstellten Vertrag inkl. Customer
|
|
||||||
// zurück, und der darf keine Passwort-Hashes/-Encryptions leaken).
|
|
||||||
if (contract.customer) {
|
|
||||||
(contract as Record<string, unknown>).customer = sanitizeCustomerStrict(
|
|
||||||
contract.customer as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,251 +765,6 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||||||
return createContract(newContractData);
|
return createContract(newContractData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration.
|
|
||||||
* Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt
|
|
||||||
* sie auf 12 Monate als sicheren Default zurück.
|
|
||||||
*/
|
|
||||||
function durationToMonths(code: string | null | undefined, description: string | null | undefined): number {
|
|
||||||
const c = (code || '').trim();
|
|
||||||
const d = (description || '').trim();
|
|
||||||
let m = c.match(/^(\d+)\s*M$/i);
|
|
||||||
if (m) return parseInt(m[1], 10);
|
|
||||||
m = c.match(/^(\d+)\s*J$/i);
|
|
||||||
if (m) return parseInt(m[1], 10) * 12;
|
|
||||||
m = d.match(/(\d+)\s*Monat/i);
|
|
||||||
if (m) return parseInt(m[1], 10);
|
|
||||||
m = d.match(/(\d+)\s*Jahr/i);
|
|
||||||
if (m) return parseInt(m[1], 10) * 12;
|
|
||||||
return 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VVL = Vertragsverlängerung beim selben Anbieter.
|
|
||||||
*
|
|
||||||
* Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert:
|
|
||||||
* Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments.
|
|
||||||
*
|
|
||||||
* Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit.
|
|
||||||
* Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann
|
|
||||||
* der User es im Vertrag manuell anpassen.
|
|
||||||
*
|
|
||||||
* NICHT mitkopiert wird:
|
|
||||||
* - das Auftragsdokument (documentType "Auftragsformular") – das ist
|
|
||||||
* schließlich die NEU zu unterschreibende VVL.
|
|
||||||
* - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow,
|
|
||||||
* bei einer VVL nicht relevant)
|
|
||||||
*/
|
|
||||||
export async function createRenewalContract(previousContractId: number) {
|
|
||||||
const previousContract = await getContractById(previousContractId, true);
|
|
||||||
if (!previousContract) {
|
|
||||||
throw new Error('Vorgängervertrag nicht gefunden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bereits ein Folge-/VVL-Vertrag vorhanden?
|
|
||||||
const existing = await prisma.contract.findFirst({
|
|
||||||
where: { previousContractId },
|
|
||||||
select: { id: true, contractNumber: true },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neues Startdatum = altes Start + Laufzeit
|
|
||||||
let newStartDate: Date | null = null;
|
|
||||||
let newEndDate: Date | null = null;
|
|
||||||
if (previousContract.startDate && previousContract.contractDuration) {
|
|
||||||
const months = durationToMonths(
|
|
||||||
previousContract.contractDuration.code,
|
|
||||||
previousContract.contractDuration.description,
|
|
||||||
);
|
|
||||||
newStartDate = new Date(previousContract.startDate);
|
|
||||||
newStartDate.setMonth(newStartDate.getMonth() + months);
|
|
||||||
newEndDate = new Date(newStartDate);
|
|
||||||
newEndDate.setMonth(newEndDate.getMonth() + months);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder)
|
|
||||||
const contractNumber = generateContractNumber(previousContract.type);
|
|
||||||
|
|
||||||
const newContract = await prisma.contract.create({
|
|
||||||
data: {
|
|
||||||
contractNumber,
|
|
||||||
customerId: previousContract.customerId,
|
|
||||||
type: previousContract.type,
|
|
||||||
status: 'DRAFT',
|
|
||||||
contractCategoryId: previousContract.contractCategoryId,
|
|
||||||
addressId: previousContract.addressId,
|
|
||||||
billingAddressId: previousContract.billingAddressId,
|
|
||||||
bankCardId: previousContract.bankCardId,
|
|
||||||
identityDocumentId: previousContract.identityDocumentId,
|
|
||||||
salesPlatformId: previousContract.salesPlatformId,
|
|
||||||
cancellationPeriodId: previousContract.cancellationPeriodId,
|
|
||||||
contractDurationId: previousContract.contractDurationId,
|
|
||||||
previousContractId: previousContract.id,
|
|
||||||
previousProviderId: previousContract.previousProviderId,
|
|
||||||
providerId: previousContract.providerId,
|
|
||||||
tariffId: previousContract.tariffId,
|
|
||||||
providerName: previousContract.providerName,
|
|
||||||
tariffName: previousContract.tariffName,
|
|
||||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
|
||||||
portalUsername: previousContract.portalUsername,
|
|
||||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
|
||||||
commission: previousContract.commission,
|
|
||||||
notes: previousContract.notes,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
// Cancellation-Felder bewusst leer lassen – die VVL hat den alten
|
|
||||||
// Cancel-Flow nicht geerbt.
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu)
|
|
||||||
if (previousContract.energyDetails) {
|
|
||||||
const ed = previousContract.energyDetails;
|
|
||||||
const newEnergy = await prisma.energyContractDetails.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
meterId: ed.meterId,
|
|
||||||
maloId: ed.maloId,
|
|
||||||
annualConsumption: ed.annualConsumption,
|
|
||||||
annualConsumptionKwh: ed.annualConsumptionKwh,
|
|
||||||
basePrice: ed.basePrice,
|
|
||||||
unitPrice: ed.unitPrice,
|
|
||||||
unitPriceNt: ed.unitPriceNt,
|
|
||||||
bonus: ed.bonus,
|
|
||||||
previousProviderName: ed.previousProviderName,
|
|
||||||
previousCustomerNumber: ed.previousCustomerNumber,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// ContractMeter-Verknüpfungen mitkopieren
|
|
||||||
for (const cm of ed.contractMeters || []) {
|
|
||||||
await prisma.contractMeter.create({
|
|
||||||
data: {
|
|
||||||
energyContractDetailsId: newEnergy.id,
|
|
||||||
meterId: cm.meterId,
|
|
||||||
position: cm.position,
|
|
||||||
installedAt: cm.installedAt,
|
|
||||||
removedAt: cm.removedAt,
|
|
||||||
finalReading: cm.finalReading,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (previousContract.internetDetails) {
|
|
||||||
const id = previousContract.internetDetails;
|
|
||||||
const newInet = await prisma.internetContractDetails.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
downloadSpeed: id.downloadSpeed,
|
|
||||||
uploadSpeed: id.uploadSpeed,
|
|
||||||
routerModel: id.routerModel,
|
|
||||||
routerSerialNumber: id.routerSerialNumber,
|
|
||||||
installationDate: id.installationDate,
|
|
||||||
internetUsername: id.internetUsername,
|
|
||||||
internetPasswordEncrypted: id.internetPasswordEncrypted,
|
|
||||||
propertyType: id.propertyType,
|
|
||||||
propertyLocation: id.propertyLocation,
|
|
||||||
connectionLocation: id.connectionLocation,
|
|
||||||
homeId: id.homeId,
|
|
||||||
activationCode: id.activationCode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for (const pn of id.phoneNumbers || []) {
|
|
||||||
await prisma.phoneNumber.create({
|
|
||||||
data: {
|
|
||||||
internetContractDetailsId: newInet.id,
|
|
||||||
phoneNumber: pn.phoneNumber,
|
|
||||||
isMain: pn.isMain,
|
|
||||||
sipUsername: pn.sipUsername,
|
|
||||||
sipPasswordEncrypted: pn.sipPasswordEncrypted,
|
|
||||||
sipServer: pn.sipServer,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (previousContract.mobileDetails) {
|
|
||||||
const md = previousContract.mobileDetails;
|
|
||||||
const newMob = await prisma.mobileContractDetails.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
requiresMultisim: md.requiresMultisim,
|
|
||||||
dataVolume: md.dataVolume,
|
|
||||||
includedMinutes: md.includedMinutes,
|
|
||||||
includedSMS: md.includedSMS,
|
|
||||||
deviceModel: md.deviceModel,
|
|
||||||
deviceImei: md.deviceImei,
|
|
||||||
phoneNumber: md.phoneNumber,
|
|
||||||
simCardNumber: md.simCardNumber,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for (const sc of md.simCards || []) {
|
|
||||||
await prisma.simCard.create({
|
|
||||||
data: {
|
|
||||||
mobileDetailsId: newMob.id,
|
|
||||||
phoneNumber: sc.phoneNumber,
|
|
||||||
simCardNumber: sc.simCardNumber,
|
|
||||||
isMultisim: sc.isMultisim,
|
|
||||||
isMain: sc.isMain,
|
|
||||||
pin: sc.pin,
|
|
||||||
puk: sc.puk,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (previousContract.tvDetails) {
|
|
||||||
await prisma.tvContractDetails.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
receiverModel: previousContract.tvDetails.receiverModel,
|
|
||||||
smartcardNumber: previousContract.tvDetails.smartcardNumber,
|
|
||||||
package: previousContract.tvDetails.package,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (previousContract.carInsuranceDetails) {
|
|
||||||
const ci = previousContract.carInsuranceDetails;
|
|
||||||
await prisma.carInsuranceDetails.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
licensePlate: ci.licensePlate,
|
|
||||||
hsn: ci.hsn,
|
|
||||||
tsn: ci.tsn,
|
|
||||||
vin: ci.vin,
|
|
||||||
vehicleType: ci.vehicleType,
|
|
||||||
firstRegistration: ci.firstRegistration,
|
|
||||||
noClaimsClass: ci.noClaimsClass,
|
|
||||||
insuranceType: ci.insuranceType,
|
|
||||||
deductiblePartial: ci.deductiblePartial,
|
|
||||||
deductibleFull: ci.deductibleFull,
|
|
||||||
previousInsurer: ci.previousInsurer,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContractDocuments mitkopieren – AUSSER "Auftragsformular" (das ist die
|
|
||||||
// neue Unterschrift, die der User selbst hochlädt). Files werden NICHT
|
|
||||||
// physisch dupliziert; beide Verträge zeigen auf dieselbe Datei.
|
|
||||||
const docs = await prisma.contractDocument.findMany({
|
|
||||||
where: { contractId: previousContract.id },
|
|
||||||
});
|
|
||||||
for (const d of docs) {
|
|
||||||
if (d.documentType.toLowerCase().includes('auftragsformular')) continue;
|
|
||||||
await prisma.contractDocument.create({
|
|
||||||
data: {
|
|
||||||
contractId: newContract.id,
|
|
||||||
documentType: d.documentType,
|
|
||||||
documentPath: d.documentPath,
|
|
||||||
originalName: d.originalName,
|
|
||||||
notes: d.notes,
|
|
||||||
uploadedBy: d.uploadedBy,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.contract.findUnique({ where: { id: newContract.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt password for viewing
|
// Decrypt password for viewing
|
||||||
export async function getContractPassword(id: number): Promise<string | null> {
|
export async function getContractPassword(id: number): Promise<string | null> {
|
||||||
const contract = await prisma.contract.findUnique({
|
const contract = await prisma.contract.findUnique({
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
|
|||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
|
export async function getCockpitData(): Promise<CockpitResult> {
|
||||||
// Lade Einstellungen
|
// Lade Einstellungen
|
||||||
const settings = await appSettingService.getAllSettings();
|
const settings = await appSettingService.getAllSettings();
|
||||||
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
|
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
|
||||||
@@ -192,19 +192,12 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
|
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
|
||||||
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
|
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
|
||||||
|
|
||||||
// Portal-Filter: Wenn customerIds gesetzt sind (Kundenportal-User), beschränken
|
|
||||||
// wir ALLE Cockpit-Queries auf diese Customer-IDs. Leeres Array → keine Treffer.
|
|
||||||
const customerScopeFilter = opts?.customerIds
|
|
||||||
? { customerId: { in: opts.customerIds } }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
|
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
|
||||||
const contracts = await prisma.contract.findMany({
|
const contracts = await prisma.contract.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
|
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
|
||||||
},
|
},
|
||||||
...customerScopeFilter,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
customer: {
|
customer: {
|
||||||
@@ -290,9 +283,9 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Consent-Daten batch-laden für alle (erlaubten) Kunden
|
// Consent-Daten batch-laden für alle Kunden
|
||||||
const allConsents = await prisma.customerConsent.findMany({
|
const allConsents = await prisma.customerConsent.findMany({
|
||||||
where: { status: 'GRANTED', ...customerScopeFilter },
|
where: { status: 'GRANTED' },
|
||||||
select: { customerId: true, consentType: true },
|
select: { customerId: true, consentType: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,7 +300,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
|
|
||||||
// Widerrufene Consents laden
|
// Widerrufene Consents laden
|
||||||
const withdrawnConsents = await prisma.customerConsent.findMany({
|
const withdrawnConsents = await prisma.customerConsent.findMany({
|
||||||
where: { status: 'WITHDRAWN', ...customerScopeFilter },
|
where: { status: 'WITHDRAWN' },
|
||||||
select: { customerId: true, consentType: true },
|
select: { customerId: true, consentType: true },
|
||||||
});
|
});
|
||||||
const withdrawnConsentsMap = new Map<number, Set<string>>();
|
const withdrawnConsentsMap = new Map<number, Set<string>>();
|
||||||
@@ -740,10 +733,10 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Vertragsunabhängige Ausweis-Warnungen
|
// Vertragsunabhängige Ausweis-Warnungen
|
||||||
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
|
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays);
|
||||||
|
|
||||||
// Gemeldete Zählerstände (REPORTED Status)
|
// Gemeldete Zählerstände (REPORTED Status)
|
||||||
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
|
const reportedReadings = await getReportedMeterReadings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contracts: cockpitContracts,
|
contracts: cockpitContracts,
|
||||||
@@ -761,11 +754,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
/**
|
/**
|
||||||
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
|
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
|
||||||
*/
|
*/
|
||||||
async function getDocumentExpiryAlerts(
|
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> {
|
||||||
criticalDays: number,
|
|
||||||
warningDays: number,
|
|
||||||
customerIds?: number[],
|
|
||||||
): Promise<DocumentAlert[]> {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
|
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -773,7 +762,6 @@ async function getDocumentExpiryAlerts(
|
|||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
expiryDate: { lte: inWarningDays },
|
expiryDate: { lte: inWarningDays },
|
||||||
...(customerIds ? { customerId: { in: customerIds } } : {}),
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
customer: {
|
customer: {
|
||||||
@@ -810,12 +798,9 @@ async function getDocumentExpiryAlerts(
|
|||||||
/**
|
/**
|
||||||
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
|
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
|
||||||
*/
|
*/
|
||||||
async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
|
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
|
||||||
const readings = await prisma.meterReading.findMany({
|
const readings = await prisma.meterReading.findMany({
|
||||||
where: {
|
where: { status: 'REPORTED' },
|
||||||
status: 'REPORTED',
|
|
||||||
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
meter: {
|
meter: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -129,35 +129,3 @@ export async function createNewContractFromPredecessorEntry(
|
|||||||
createdBy,
|
createdBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatischen Historie-Eintrag für VVL (Vertragsverlängerung) im Vorgängervertrag.
|
|
||||||
*/
|
|
||||||
export async function createRenewalHistoryEntry(
|
|
||||||
previousContractId: number,
|
|
||||||
newContractNumber: string,
|
|
||||||
createdBy: string
|
|
||||||
) {
|
|
||||||
return createHistoryEntry(previousContractId, {
|
|
||||||
title: `Vertragsverlängerung erstellt: ${newContractNumber}`,
|
|
||||||
description: `Eine Vertragsverlängerung (VVL) als ${newContractNumber} wurde aus diesem Vertrag erstellt – alle Daten wurden 1:1 übernommen, das Auftragsdokument muss neu hochgeladen werden.`,
|
|
||||||
isAutomatic: true,
|
|
||||||
createdBy,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatischen Historie-Eintrag im neuen VVL-Vertrag.
|
|
||||||
*/
|
|
||||||
export async function createNewRenewalFromPredecessorEntry(
|
|
||||||
newContractId: number,
|
|
||||||
previousContractNumber: string,
|
|
||||||
createdBy: string
|
|
||||||
) {
|
|
||||||
return createHistoryEntry(newContractId, {
|
|
||||||
title: `VVL zu ${previousContractNumber}`,
|
|
||||||
description: `Dieser Vertrag wurde als Vertragsverlängerung (VVL) zu ${previousContractNumber} erstellt.`,
|
|
||||||
isAutomatic: true,
|
|
||||||
createdBy,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scheduler für automatische Vertrags-Status-Übergänge.
|
|
||||||
*
|
|
||||||
* Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und
|
|
||||||
* endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log).
|
|
||||||
*
|
|
||||||
* Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls
|
|
||||||
* der Prozess zum 02:00-Slot neu gestartet wurde.
|
|
||||||
*/
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { createAuditLog, logChange } from './audit.service.js';
|
|
||||||
|
|
||||||
async function runExpireCheck(): Promise<void> {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const expiring = await prisma.contract.findMany({
|
|
||||||
where: {
|
|
||||||
status: 'ACTIVE',
|
|
||||||
endDate: { not: null, lt: today },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
contractNumber: true,
|
|
||||||
customerId: true,
|
|
||||||
endDate: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (expiring.length === 0) {
|
|
||||||
console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`);
|
|
||||||
|
|
||||||
for (const c of expiring) {
|
|
||||||
try {
|
|
||||||
await prisma.contract.update({
|
|
||||||
where: { id: c.id },
|
|
||||||
data: { status: 'EXPIRED' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userEmail: 'system',
|
|
||||||
userRole: 'System',
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'Contract',
|
|
||||||
resourceId: c.id.toString(),
|
|
||||||
resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`,
|
|
||||||
endpoint: 'scheduler:contract-status',
|
|
||||||
httpMethod: 'SYSTEM',
|
|
||||||
ipAddress: 'localhost',
|
|
||||||
dataSubjectId: c.customerId,
|
|
||||||
changesBefore: { status: 'ACTIVE' },
|
|
||||||
changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ContractStatusScheduler] Fertig.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startContractStatusScheduler(): void {
|
|
||||||
// Täglich um 02:00 Uhr (Server-Zeit)
|
|
||||||
cron.schedule('0 2 * * *', () => {
|
|
||||||
runExpireCheck().catch((err) =>
|
|
||||||
console.error('[ContractStatusScheduler] Daily run failed:', err),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Catch-up 60 Sekunden nach Start
|
|
||||||
setTimeout(() => {
|
|
||||||
runExpireCheck().catch((err) =>
|
|
||||||
console.error('[ContractStatusScheduler] Catch-up run failed:', err),
|
|
||||||
);
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
console.log('[ContractStatusScheduler] Gestartet – täglich um 02:00 + Catch-up nach 60s');
|
|
||||||
}
|
|
||||||
|
|
||||||
export { runExpireCheck };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
|
||||||
* Lieferbestätigung ist:
|
|
||||||
* - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT)
|
|
||||||
* - Contract.startDate auf deliveryDate (oder heute) setzen, falls noch leer
|
|
||||||
*
|
|
||||||
* Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown
|
|
||||||
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
|
|
||||||
* getrimmt zur Robustheit.
|
|
||||||
*/
|
|
||||||
export async function maybeActivateOnDeliveryConfirmation(
|
|
||||||
contractId: number,
|
|
||||||
documentType: string,
|
|
||||||
req: unknown,
|
|
||||||
deliveryDate?: Date | string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!documentType || typeof documentType !== 'string') return;
|
|
||||||
if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return;
|
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({
|
|
||||||
where: { id: contractId },
|
|
||||||
select: { status: true, contractNumber: true, customerId: true, startDate: true },
|
|
||||||
});
|
|
||||||
if (!contract) return;
|
|
||||||
|
|
||||||
// deliveryDate parsen, Fallback auf heute
|
|
||||||
let parsedDate: Date | null = null;
|
|
||||||
if (deliveryDate) {
|
|
||||||
const parsed = new Date(deliveryDate);
|
|
||||||
if (!isNaN(parsed.getTime())) parsedDate = parsed;
|
|
||||||
}
|
|
||||||
const effectiveDate = parsedDate || new Date();
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
|
||||||
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
|
||||||
|
|
||||||
if (contract.status === 'DRAFT') {
|
|
||||||
updateData.status = 'ACTIVE';
|
|
||||||
changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contract.startDate) {
|
|
||||||
updateData.startDate = effectiveDate;
|
|
||||||
changes.startDate = { vorher: null, nachher: effectiveDate.toISOString().split('T')[0] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) return;
|
|
||||||
|
|
||||||
await prisma.contract.update({
|
|
||||||
where: { id: contractId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
await logChange({
|
|
||||||
req,
|
|
||||||
action: 'UPDATE',
|
|
||||||
resourceType: 'Contract',
|
|
||||||
resourceId: contractId.toString(),
|
|
||||||
label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`,
|
|
||||||
details: { ...changes, trigger: 'Lieferbestätigung-Upload' },
|
|
||||||
customerId: contract.customerId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -22,13 +22,10 @@ export interface CustomerFilters {
|
|||||||
type?: CustomerType;
|
type?: CustomerType;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
// Wenn gesetzt: nur Customer mit id in dieser Liste. Für Portal-User, damit
|
|
||||||
// weder Liste noch pagination.total die globale Kunden-Zahl preisgibt.
|
|
||||||
allowedIds?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllCustomers(filters: CustomerFilters) {
|
export async function getAllCustomers(filters: CustomerFilters) {
|
||||||
const { search, type, page = 1, limit = 20, allowedIds } = filters;
|
const { search, type, page = 1, limit = 20 } = filters;
|
||||||
const { skip, take } = paginate(page, limit);
|
const { skip, take } = paginate(page, limit);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
@@ -37,10 +34,6 @@ export async function getAllCustomers(filters: CustomerFilters) {
|
|||||||
where.type = type;
|
where.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedIds) {
|
|
||||||
where.id = { in: allowedIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ firstName: { contains: search } },
|
{ firstName: { contains: search } },
|
||||||
|
|||||||
@@ -469,22 +469,6 @@ export async function deprovisionEmail(localPart: string): Promise<EmailOperatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weiterleitungsziele ersetzen (set:, nicht add:) – nutzen wir, um nach einer
|
|
||||||
// Kunden-Email-Änderung die Forwards einer Stressfrei-Adresse auf den neuen
|
|
||||||
// Kunden-Inbox + unsere Service-Adresse zu setzen.
|
|
||||||
export async function setEmailForwardTargets(
|
|
||||||
localPart: string,
|
|
||||||
targets: string[],
|
|
||||||
): Promise<EmailOperationResult> {
|
|
||||||
try {
|
|
||||||
const provider = await getProviderInstance();
|
|
||||||
return provider.updateForwardTargets(localPart, targets);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// E-Mail umbenennen
|
// E-Mail umbenennen
|
||||||
export async function renameProvisionedEmail(
|
export async function renameProvisionedEmail(
|
||||||
oldLocalPart: string,
|
oldLocalPart: string,
|
||||||
|
|||||||
@@ -1,32 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
||||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails –
|
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen –
|
||||||
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||||
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
|
* Vertragskategorien und PDF-Auftragsvorlagen.
|
||||||
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import AdmZip from 'adm-zip';
|
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören.
|
|
||||||
// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte –
|
|
||||||
// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings.
|
|
||||||
export const FACTORY_DEFAULT_APP_SETTING_KEYS = [
|
|
||||||
'privacyPolicyHtml',
|
|
||||||
'authorizationTemplateHtml',
|
|
||||||
'imprintHtml',
|
|
||||||
'websitePrivacyPolicyHtml',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface AppSettingExport {
|
|
||||||
key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number];
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FactoryDefaultsManifest {
|
export interface FactoryDefaultsManifest {
|
||||||
version: 1;
|
version: 1;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
@@ -37,7 +20,6 @@ export interface FactoryDefaultsManifest {
|
|||||||
contractDurations: number;
|
contractDurations: number;
|
||||||
contractCategories: number;
|
contractCategories: number;
|
||||||
pdfTemplates: number;
|
pdfTemplates: number;
|
||||||
appSettings: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +49,7 @@ export interface PdfTemplateExport {
|
|||||||
* Sammelt alle Katalog-Daten aus der DB.
|
* Sammelt alle Katalog-Daten aus der DB.
|
||||||
*/
|
*/
|
||||||
export async function collectFactoryDefaults() {
|
export async function collectFactoryDefaults() {
|
||||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
|
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.provider.findMany({
|
prisma.provider.findMany({
|
||||||
include: { tariffs: { select: { name: true, isActive: true } } },
|
include: { tariffs: { select: { name: true, isActive: true } } },
|
||||||
@@ -77,11 +59,6 @@ export async function collectFactoryDefaults() {
|
|||||||
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
||||||
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||||
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
||||||
prisma.appSetting.findMany({
|
|
||||||
where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } },
|
|
||||||
select: { key: true, value: true },
|
|
||||||
orderBy: { key: 'asc' },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,7 +108,6 @@ export async function collectFactoryDefaults() {
|
|||||||
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
appSettings: appSettings as AppSettingExport[],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +132,6 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
appSettings: data.appSettings.length,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,9 +160,6 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
||||||
name: 'pdf-templates/pdf-templates.json',
|
name: 'pdf-templates/pdf-templates.json',
|
||||||
});
|
});
|
||||||
archive.append(JSON.stringify(data.appSettings, null, 2), {
|
|
||||||
name: 'app-settings/app-settings.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
||||||
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||||
@@ -220,244 +192,3 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// IMPORT
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export interface FactoryDefaultsImportResult {
|
|
||||||
providers: number;
|
|
||||||
tariffs: number;
|
|
||||||
cancellationPeriods: number;
|
|
||||||
contractDurations: number;
|
|
||||||
contractCategories: number;
|
|
||||||
pdfTemplates: number;
|
|
||||||
pdfTemplatesSkipped: number;
|
|
||||||
appSettings: number;
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonEntry<T>(zip: AdmZip, name: string): T[] {
|
|
||||||
const entry = zip.getEntry(name);
|
|
||||||
if (!entry) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(entry.getData().toString('utf-8'));
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wendet ein Factory-Defaults-ZIP idempotent auf die DB an.
|
|
||||||
* - upsert über unique-Keys: nichts wird gelöscht
|
|
||||||
* - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix
|
|
||||||
* - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS)
|
|
||||||
*
|
|
||||||
* Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu
|
|
||||||
* (`pdf-templates/<basename>`), niemals auf einen aus dem ZIP konstruierten
|
|
||||||
* Pfad im Filesystem.
|
|
||||||
*/
|
|
||||||
export async function importFactoryDefaults(
|
|
||||||
zipBuffer: Buffer,
|
|
||||||
): Promise<FactoryDefaultsImportResult> {
|
|
||||||
const zip = new AdmZip(zipBuffer);
|
|
||||||
const result: FactoryDefaultsImportResult = {
|
|
||||||
providers: 0,
|
|
||||||
tariffs: 0,
|
|
||||||
cancellationPeriods: 0,
|
|
||||||
contractDurations: 0,
|
|
||||||
contractCategories: 0,
|
|
||||||
pdfTemplates: 0,
|
|
||||||
pdfTemplatesSkipped: 0,
|
|
||||||
appSettings: 0,
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Providers + Tariffs
|
|
||||||
const providers = parseJsonEntry<ProviderExport>(zip, 'providers/providers.json');
|
|
||||||
for (const p of providers) {
|
|
||||||
if (!p.name) continue;
|
|
||||||
const provider = await prisma.provider.upsert({
|
|
||||||
where: { name: p.name },
|
|
||||||
update: {
|
|
||||||
portalUrl: p.portalUrl ?? null,
|
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
|
||||||
passwordFieldName: p.passwordFieldName ?? null,
|
|
||||||
isActive: p.isActive ?? true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: p.name,
|
|
||||||
portalUrl: p.portalUrl ?? null,
|
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
|
||||||
passwordFieldName: p.passwordFieldName ?? null,
|
|
||||||
isActive: p.isActive ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
result.providers++;
|
|
||||||
for (const t of p.tariffs ?? []) {
|
|
||||||
if (!t.name) continue;
|
|
||||||
await prisma.tariff.upsert({
|
|
||||||
where: { providerId_name: { providerId: provider.id, name: t.name } },
|
|
||||||
update: { isActive: t.isActive ?? true },
|
|
||||||
create: { providerId: provider.id, name: t.name, isActive: t.isActive ?? true },
|
|
||||||
});
|
|
||||||
result.tariffs++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Contract-Meta
|
|
||||||
const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
|
||||||
zip,
|
|
||||||
'contract-meta/cancellation-periods.json',
|
|
||||||
);
|
|
||||||
for (const c of cancellationPeriods) {
|
|
||||||
if (!c.code || !c.description) continue;
|
|
||||||
await prisma.cancellationPeriod.upsert({
|
|
||||||
where: { code: c.code },
|
|
||||||
update: { description: c.description, isActive: c.isActive ?? true },
|
|
||||||
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
|
|
||||||
});
|
|
||||||
result.cancellationPeriods++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
|
||||||
zip,
|
|
||||||
'contract-meta/contract-durations.json',
|
|
||||||
);
|
|
||||||
for (const d of contractDurations) {
|
|
||||||
if (!d.code || !d.description) continue;
|
|
||||||
await prisma.contractDuration.upsert({
|
|
||||||
where: { code: d.code },
|
|
||||||
update: { description: d.description, isActive: d.isActive ?? true },
|
|
||||||
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
|
|
||||||
});
|
|
||||||
result.contractDurations++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contractCategories = parseJsonEntry<{
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
icon?: string | null;
|
|
||||||
color?: string | null;
|
|
||||||
sortOrder?: number;
|
|
||||||
isActive?: boolean;
|
|
||||||
}>(zip, 'contract-meta/contract-categories.json');
|
|
||||||
for (const c of contractCategories) {
|
|
||||||
if (!c.code || !c.name) continue;
|
|
||||||
await prisma.contractCategory.upsert({
|
|
||||||
where: { code: c.code },
|
|
||||||
update: {
|
|
||||||
name: c.name,
|
|
||||||
icon: c.icon ?? null,
|
|
||||||
color: c.color ?? null,
|
|
||||||
sortOrder: c.sortOrder ?? 0,
|
|
||||||
isActive: c.isActive ?? true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
code: c.code,
|
|
||||||
name: c.name,
|
|
||||||
icon: c.icon ?? null,
|
|
||||||
color: c.color ?? null,
|
|
||||||
sortOrder: c.sortOrder ?? 0,
|
|
||||||
isActive: c.isActive ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
result.contractCategories++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PDF-Vorlagen (JSON + binär aus dem ZIP)
|
|
||||||
const pdfTemplates = parseJsonEntry<PdfTemplateExport>(
|
|
||||||
zip,
|
|
||||||
'pdf-templates/pdf-templates.json',
|
|
||||||
);
|
|
||||||
if (pdfTemplates.length > 0) {
|
|
||||||
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
|
||||||
const pdfDestDir = path.join(uploadsRoot, 'pdf-templates');
|
|
||||||
if (!fs.existsSync(pdfDestDir)) {
|
|
||||||
fs.mkdirSync(pdfDestDir, { recursive: true });
|
|
||||||
}
|
|
||||||
for (const t of pdfTemplates) {
|
|
||||||
if (!t.name || !t.pdfFilename) continue;
|
|
||||||
// Anti-Zip-Slip: nur basename verwenden, kein Pfad
|
|
||||||
const basename = path.basename(t.pdfFilename);
|
|
||||||
const entry = zip.getEntry(`pdf-templates/${basename}`);
|
|
||||||
if (!entry) {
|
|
||||||
result.pdfTemplatesSkipped++;
|
|
||||||
result.warnings.push(`PDF fehlt im ZIP: ${basename} – Vorlage "${t.name}" übersprungen`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(t.originalName || basename) || '.pdf';
|
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
||||||
const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-');
|
|
||||||
const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`;
|
|
||||||
const destPdf = path.join(pdfDestDir, destFilename);
|
|
||||||
const relativePath = `/uploads/pdf-templates/${destFilename}`;
|
|
||||||
|
|
||||||
fs.writeFileSync(destPdf, entry.getData());
|
|
||||||
|
|
||||||
// Bei existierender Vorlage die alte Datei aufräumen
|
|
||||||
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
|
|
||||||
if (existing?.templatePath) {
|
|
||||||
const oldRel = existing.templatePath.startsWith('/uploads/')
|
|
||||||
? existing.templatePath.substring('/uploads/'.length)
|
|
||||||
: existing.templatePath;
|
|
||||||
const oldAbs = path.join(uploadsRoot, oldRel);
|
|
||||||
if (fs.existsSync(oldAbs)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(oldAbs);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {});
|
|
||||||
await prisma.pdfTemplate.upsert({
|
|
||||||
where: { name: t.name },
|
|
||||||
update: {
|
|
||||||
description: t.description ?? null,
|
|
||||||
providerName: t.providerName ?? null,
|
|
||||||
templatePath: relativePath,
|
|
||||||
originalName: t.originalName,
|
|
||||||
fieldMapping: fieldMappingJson,
|
|
||||||
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
|
||||||
maxPhoneFields: t.maxPhoneFields ?? 8,
|
|
||||||
isActive: t.isActive ?? true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: t.name,
|
|
||||||
description: t.description ?? null,
|
|
||||||
providerName: t.providerName ?? null,
|
|
||||||
templatePath: relativePath,
|
|
||||||
originalName: t.originalName,
|
|
||||||
fieldMapping: fieldMappingJson,
|
|
||||||
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
|
|
||||||
maxPhoneFields: t.maxPhoneFields ?? 8,
|
|
||||||
isActive: t.isActive ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
result.pdfTemplates++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AppSettings (HTML-Templates, Whitelist)
|
|
||||||
const appSettings = parseJsonEntry<AppSettingExport>(zip, 'app-settings/app-settings.json');
|
|
||||||
const allowedKeys = new Set<string>(FACTORY_DEFAULT_APP_SETTING_KEYS);
|
|
||||||
for (const s of appSettings) {
|
|
||||||
if (!s.key || typeof s.value !== 'string') continue;
|
|
||||||
if (!allowedKeys.has(s.key)) {
|
|
||||||
result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist – ignoriert`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await prisma.appSetting.upsert({
|
|
||||||
where: { key: s.key },
|
|
||||||
update: { value: s.value },
|
|
||||||
create: { key: s.key, value: s.value },
|
|
||||||
});
|
|
||||||
result.appSettings++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pfad → Resource → Owner Mapping für `/api/files/download`.
|
|
||||||
*
|
|
||||||
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
|
|
||||||
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
|
|
||||||
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
|
|
||||||
* canAccessContract entscheidet danach über Zugriff.
|
|
||||||
*
|
|
||||||
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
|
|
||||||
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
|
|
||||||
* automatisch ausgeschlossen – ein konstruierter Pfad findet keinen Record.
|
|
||||||
*/
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
|
|
||||||
export type FileOwner =
|
|
||||||
| { kind: 'customer'; customerId: number }
|
|
||||||
| { kind: 'contract'; contractId: number }
|
|
||||||
| { kind: 'admin' }
|
|
||||||
| { kind: 'gdpr-admin' };
|
|
||||||
|
|
||||||
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
|
|
||||||
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
|
|
||||||
if (!uploadPath.startsWith('/uploads/')) return null;
|
|
||||||
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
|
|
||||||
|
|
||||||
const parts = uploadPath.split('/');
|
|
||||||
// ['', 'uploads', '<subDir>', '<filename...>']
|
|
||||||
if (parts.length < 4) return null;
|
|
||||||
const subDir = parts[2];
|
|
||||||
|
|
||||||
switch (subDir) {
|
|
||||||
case 'bank-cards': {
|
|
||||||
const r = await prisma.bankCard.findFirst({
|
|
||||||
where: { documentPath: uploadPath },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'documents': {
|
|
||||||
const r = await prisma.identityDocument.findFirst({
|
|
||||||
where: { documentPath: uploadPath },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'business-registrations': {
|
|
||||||
const r = await prisma.customer.findFirst({
|
|
||||||
where: { businessRegistrationPath: uploadPath },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'commercial-registers': {
|
|
||||||
const r = await prisma.customer.findFirst({
|
|
||||||
where: { commercialRegisterPath: uploadPath },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'privacy-policies': {
|
|
||||||
const r = await prisma.customer.findFirst({
|
|
||||||
where: { privacyPolicyPath: uploadPath },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'authorizations': {
|
|
||||||
const r = await prisma.representativeAuthorization.findFirst({
|
|
||||||
where: { documentPath: uploadPath },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'contract-documents': {
|
|
||||||
const r = await prisma.contractDocument.findFirst({
|
|
||||||
where: { documentPath: uploadPath },
|
|
||||||
select: { contractId: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'contract', contractId: r.contractId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'invoices': {
|
|
||||||
const r = await prisma.invoice.findFirst({
|
|
||||||
where: { documentPath: uploadPath },
|
|
||||||
select: { contractId: true },
|
|
||||||
});
|
|
||||||
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'cancellation-letters':
|
|
||||||
case 'cancellation-confirmations':
|
|
||||||
case 'cancellation-letters-options':
|
|
||||||
case 'cancellation-confirmations-options': {
|
|
||||||
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
|
|
||||||
'cancellation-letters': 'cancellationLetterPath',
|
|
||||||
'cancellation-confirmations': 'cancellationConfirmationPath',
|
|
||||||
'cancellation-letters-options': 'cancellationLetterOptionsPath',
|
|
||||||
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
|
|
||||||
};
|
|
||||||
const field = fieldMap[subDir];
|
|
||||||
const r = await prisma.contract.findFirst({
|
|
||||||
where: { [field]: uploadPath },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'contract', contractId: r.id } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'pdf-templates': {
|
|
||||||
// Admin-only Resource: Vorlagen gehören keinem Customer.
|
|
||||||
const r = await prisma.pdfTemplate.findFirst({
|
|
||||||
where: { templatePath: uploadPath },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return r ? { kind: 'admin' } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,6 @@ export interface ImapCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
|
||||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
|
||||||
servername?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,12 +29,6 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
|||||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
const options: Record<string, unknown> = { rejectUnauthorized };
|
||||||
|
|
||||||
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
|
|
||||||
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
|
|
||||||
if (credentials.servername) {
|
|
||||||
options.servername = credentials.servername;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (credentials.allowSelfSignedCerts) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
options.minVersion = 'TLSv1';
|
options.minVersion = 'TLSv1';
|
||||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
/**
|
|
||||||
* Security-Alerting:
|
|
||||||
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
|
|
||||||
* Cron alle 60s gepollt) – z.B. Threshold-Überschreitungen.
|
|
||||||
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
|
|
||||||
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
|
|
||||||
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
|
|
||||||
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
|
|
||||||
* Events wenn die Schwelle erreicht ist.
|
|
||||||
*
|
|
||||||
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
|
|
||||||
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
|
|
||||||
*/
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
|
||||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
|
||||||
import * as appSettingService from './appSetting.service.js';
|
|
||||||
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
|
||||||
import type { SecurityEvent } from '@prisma/client';
|
|
||||||
|
|
||||||
interface AlertEmailParams {
|
|
||||||
subject: string;
|
|
||||||
events: SecurityEvent[];
|
|
||||||
isDigest: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SendResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityIcon(s: string): string {
|
|
||||||
switch (s) {
|
|
||||||
case 'CRITICAL': return '🚨';
|
|
||||||
case 'HIGH': return '⚠️';
|
|
||||||
case 'MEDIUM': return '🟡';
|
|
||||||
case 'LOW': return '🟢';
|
|
||||||
default: return 'ℹ️';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventToHtmlRow(e: SecurityEvent): string {
|
|
||||||
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
|
|
||||||
const ip = e.ipAddress || '–';
|
|
||||||
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '–');
|
|
||||||
const ep = e.endpoint || '–';
|
|
||||||
return `<tr>
|
|
||||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
|
|
||||||
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
|
|
||||||
<td style="padding:4px 8px">${e.type}</td>
|
|
||||||
<td style="padding:4px 8px">${e.message}</td>
|
|
||||||
<td style="padding:4px 8px">${who}</td>
|
|
||||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
|
|
||||||
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHtmlEmail(params: AlertEmailParams): string {
|
|
||||||
const rows = params.events.map(eventToHtmlRow).join('\n');
|
|
||||||
const heading = params.isDigest
|
|
||||||
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
|
|
||||||
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
|
|
||||||
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
|
|
||||||
${heading}
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
|
|
||||||
<thead style="background:#f3f4f6">
|
|
||||||
<tr>
|
|
||||||
<th align="left" style="padding:6px 8px">Zeit</th>
|
|
||||||
<th align="left" style="padding:6px 8px">Severity</th>
|
|
||||||
<th align="left" style="padding:6px 8px">Typ</th>
|
|
||||||
<th align="left" style="padding:6px 8px">Nachricht</th>
|
|
||||||
<th align="left" style="padding:6px 8px">Wer</th>
|
|
||||||
<th align="left" style="padding:6px 8px">IP</th>
|
|
||||||
<th align="left" style="padding:6px 8px">Endpoint</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
|
|
||||||
Konfiguration: Einstellungen → Monitoring.</p>
|
|
||||||
</body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
|
|
||||||
*/
|
|
||||||
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
|
|
||||||
const sysEmail = await getSystemEmailCredentials();
|
|
||||||
if (!sysEmail) {
|
|
||||||
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials: SmtpCredentials = {
|
|
||||||
host: sysEmail.smtpServer,
|
|
||||||
port: sysEmail.smtpPort,
|
|
||||||
user: sysEmail.emailAddress,
|
|
||||||
password: sysEmail.password,
|
|
||||||
encryption: sysEmail.smtpEncryption,
|
|
||||||
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await sendEmail(
|
|
||||||
credentials,
|
|
||||||
sysEmail.emailAddress,
|
|
||||||
{
|
|
||||||
to: toAddress,
|
|
||||||
subject: params.subject,
|
|
||||||
html: buildHtmlEmail(params),
|
|
||||||
},
|
|
||||||
{ context: 'security-alert', triggeredBy: 'monitor' },
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.success
|
|
||||||
? { success: true }
|
|
||||||
: { success: false, error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
|
|
||||||
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
|
|
||||||
*
|
|
||||||
* Regeln (alle pro IP):
|
|
||||||
* - >= 10 LOGIN_FAILED in 60 min → CRITICAL Brute-Force-Verdacht
|
|
||||||
* - >= 5 ACCESS_DENIED in 5 min → CRITICAL IDOR-Probing-Verdacht
|
|
||||||
* - >= 3 SSRF_BLOCKED in 60 min → CRITICAL SSRF-Probing
|
|
||||||
* - >= 3 TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
|
|
||||||
*/
|
|
||||||
export async function detectThresholds(): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
||||||
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
||||||
|
|
||||||
type Bucket = {
|
|
||||||
windowStart: Date;
|
|
||||||
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
|
|
||||||
threshold: number;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
const buckets: Bucket[] = [
|
|
||||||
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
|
|
||||||
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
|
|
||||||
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
|
|
||||||
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const b of buckets) {
|
|
||||||
const grouped = await prisma.securityEvent.groupBy({
|
|
||||||
by: ['ipAddress'],
|
|
||||||
where: {
|
|
||||||
type: b.type as any,
|
|
||||||
createdAt: { gte: b.windowStart },
|
|
||||||
},
|
|
||||||
_count: true,
|
|
||||||
});
|
|
||||||
for (const g of grouped) {
|
|
||||||
if ((g._count as number) < b.threshold) continue;
|
|
||||||
// Debounce: pro IP max. 1 SUSPICIOUS-Alert pro 60min (sliding window).
|
|
||||||
// Vorher: floor(now, hour) → resettete bei Stundenwechsel und produzierte
|
|
||||||
// doppelte Alerts (Bug aus Runde 10).
|
|
||||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
||||||
const existing = await prisma.securityEvent.findFirst({
|
|
||||||
where: {
|
|
||||||
type: 'SUSPICIOUS',
|
|
||||||
severity: 'CRITICAL',
|
|
||||||
ipAddress: g.ipAddress,
|
|
||||||
createdAt: { gte: oneHourAgo },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
await emitSecurityEvent({
|
|
||||||
type: 'SUSPICIOUS',
|
|
||||||
severity: 'CRITICAL',
|
|
||||||
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
|
|
||||||
ipAddress: g.ipAddress,
|
|
||||||
details: { rule: b.type, count: g._count, threshold: b.threshold },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
|
|
||||||
* 1 Mail pro IP pro Stunde, damit nicht spammend).
|
|
||||||
*/
|
|
||||||
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
|
|
||||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
|
||||||
if (!alertEmail) return { sent: 0, skipped: 0 };
|
|
||||||
|
|
||||||
const pending = await prisma.securityEvent.findMany({
|
|
||||||
where: { severity: 'CRITICAL', alerted: false },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
take: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
for (const ev of pending) {
|
|
||||||
const result = await sendAlertEmail(alertEmail, {
|
|
||||||
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
|
|
||||||
events: [ev],
|
|
||||||
isDigest: false,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
sent++;
|
|
||||||
await prisma.securityEvent.update({
|
|
||||||
where: { id: ev.id },
|
|
||||||
data: { alerted: true, alertedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
skipped++;
|
|
||||||
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { sent, skipped };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
|
|
||||||
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
|
|
||||||
*/
|
|
||||||
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
|
|
||||||
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
|
|
||||||
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
|
|
||||||
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
|
|
||||||
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
|
|
||||||
|
|
||||||
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
|
|
||||||
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const events = await prisma.securityEvent.findMany({
|
|
||||||
where: {
|
|
||||||
severity: { in: ['HIGH', 'MEDIUM'] },
|
|
||||||
alerted: false,
|
|
||||||
createdAt: { gte: since },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
|
||||||
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
|
||||||
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await sendAlertEmail(alertEmail, {
|
|
||||||
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
|
|
||||||
events,
|
|
||||||
isDigest: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await prisma.securityEvent.updateMany({
|
|
||||||
where: { id: { in: events.map((e) => e.id) } },
|
|
||||||
data: { alerted: true, alertedAt: new Date() },
|
|
||||||
});
|
|
||||||
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
|
|
||||||
return { sent: true, eventCount: events.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sent: false, eventCount: events.length, reason: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cron-Scheduler:
|
|
||||||
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
|
|
||||||
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
|
|
||||||
*/
|
|
||||||
export function startSecurityMonitorScheduler(): void {
|
|
||||||
cron.schedule('* * * * *', async () => {
|
|
||||||
try {
|
|
||||||
await detectThresholds();
|
|
||||||
await sendPendingCriticalAlerts();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[securityAlert] minute-cron failed:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cron.schedule('0 * * * *', async () => {
|
|
||||||
try {
|
|
||||||
await sendDigest();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[securityAlert] hourly-digest failed:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
|
|
||||||
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
|
|
||||||
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
|
|
||||||
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
|
|
||||||
*
|
|
||||||
* Hooks für die wichtigsten Klassen:
|
|
||||||
* - LOGIN_FAILED → Login mit falschem Passwort
|
|
||||||
* - LOGIN_SUCCESS → erfolgreicher Login (informativ)
|
|
||||||
* - RATE_LIMIT_HIT → express-rate-limit hat zugeschlagen
|
|
||||||
* - ACCESS_DENIED → 403 von canAccess* (versuchter IDOR)
|
|
||||||
* - SSRF_BLOCKED → ssrfGuard hat geblockt
|
|
||||||
* - PASSWORD_RESET_REQUEST → Reset angefordert
|
|
||||||
* - PASSWORD_RESET_CONFIRM → Reset abgeschlossen
|
|
||||||
* - LOGOUT → expliziter Logout
|
|
||||||
* - TOKEN_REJECTED → JWT verify-Failure
|
|
||||||
* - PERMISSION_CHANGED → Rolle/Permission-Update
|
|
||||||
*
|
|
||||||
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
|
|
||||||
* konfiguriert), sonst Sammlung im stündlichen Digest.
|
|
||||||
*/
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface SecurityEventInput {
|
|
||||||
type: SecurityEventType;
|
|
||||||
severity: SecuritySeverity;
|
|
||||||
message: string;
|
|
||||||
ipAddress?: string | null;
|
|
||||||
userId?: number | null;
|
|
||||||
customerId?: number | null;
|
|
||||||
userEmail?: string | null;
|
|
||||||
endpoint?: string | null;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
|
|
||||||
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
|
|
||||||
*/
|
|
||||||
export async function emit(event: SecurityEventInput): Promise<void> {
|
|
||||||
try {
|
|
||||||
await prisma.securityEvent.create({
|
|
||||||
data: {
|
|
||||||
type: event.type,
|
|
||||||
severity: event.severity,
|
|
||||||
message: event.message,
|
|
||||||
ipAddress: event.ipAddress || null,
|
|
||||||
userId: event.userId || null,
|
|
||||||
customerId: event.customerId || null,
|
|
||||||
userEmail: event.userEmail || null,
|
|
||||||
endpoint: event.endpoint || null,
|
|
||||||
details: event.details ? (event.details as any) : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[securityMonitor] emit failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
|
|
||||||
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
|
|
||||||
* Requests (Login-Versuch etc.).
|
|
||||||
*/
|
|
||||||
export function contextFromRequest(req: any): {
|
|
||||||
ipAddress: string;
|
|
||||||
userId?: number;
|
|
||||||
customerId?: number;
|
|
||||||
userEmail?: string;
|
|
||||||
endpoint: string;
|
|
||||||
} {
|
|
||||||
const user = req?.user;
|
|
||||||
return {
|
|
||||||
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
|
|
||||||
userId: user?.userId,
|
|
||||||
customerId: user?.customerId,
|
|
||||||
userEmail: user?.email,
|
|
||||||
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -15,10 +15,6 @@ export interface SmtpCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
|
||||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
|
||||||
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
|
|
||||||
servername?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anhang-Interface
|
// Anhang-Interface
|
||||||
@@ -53,16 +49,6 @@ export interface EmailLogContext {
|
|||||||
triggeredBy?: string; // User-Email
|
triggeredBy?: string; // User-Email
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security: zentrale CRLF-Prüfung gegen SMTP-Header-Injection.
|
|
||||||
// Alle Felder, die als Header ausgehen (to/cc/subject/replyTo/references/from),
|
|
||||||
// werden hier geprüft – egal ob der Caller aus cachedEmail, birthday, gdpr,
|
|
||||||
// consent-public oder auth kommt.
|
|
||||||
function containsCRLF(value: unknown): boolean {
|
|
||||||
if (typeof value === 'string') return /[\r\n]/.test(value);
|
|
||||||
if (Array.isArray(value)) return value.some(containsCRLF);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
credentials: SmtpCredentials,
|
credentials: SmtpCredentials,
|
||||||
@@ -70,21 +56,6 @@ export async function sendEmail(
|
|||||||
params: SendEmailParams,
|
params: SendEmailParams,
|
||||||
logContext?: EmailLogContext
|
logContext?: EmailLogContext
|
||||||
): Promise<SendEmailResult> {
|
): Promise<SendEmailResult> {
|
||||||
// Header-Injection-Guard (defensiv: Absender, Empfänger, Subject)
|
|
||||||
if (
|
|
||||||
containsCRLF(fromAddress) ||
|
|
||||||
containsCRLF(params.to) ||
|
|
||||||
containsCRLF(params.cc) ||
|
|
||||||
containsCRLF(params.subject) ||
|
|
||||||
containsCRLF(params.inReplyTo) ||
|
|
||||||
containsCRLF(params.references)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Ungültige Zeichen in E-Mail-Header-Feldern (CRLF nicht erlaubt)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||||||
const encryption = credentials.encryption ?? 'SSL';
|
const encryption = credentials.encryption ?? 'SSL';
|
||||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||||
@@ -98,7 +69,7 @@ export async function sendEmail(
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
requireTLS?: boolean;
|
requireTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
@@ -120,11 +91,6 @@ export async function sendEmail(
|
|||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
|
|
||||||
// Hostname für SNI/Cert-Validation explizit setzen.
|
|
||||||
if (credentials.servername) {
|
|
||||||
transportOptions.tls.servername = credentials.servername;
|
|
||||||
}
|
|
||||||
if (credentials.allowSelfSignedCerts) {
|
if (credentials.allowSelfSignedCerts) {
|
||||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||||
transportOptions.tls.minVersion = 'TLSv1';
|
transportOptions.tls.minVersion = 'TLSv1';
|
||||||
@@ -312,7 +278,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
greetingTimeout: number;
|
greetingTimeout: number;
|
||||||
@@ -330,9 +296,6 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
if (credentials.servername) {
|
|
||||||
transportOptions.tls.servername = credentials.servername;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
checkEmailExists,
|
checkEmailExists,
|
||||||
getProviderDomain,
|
getProviderDomain,
|
||||||
updateMailboxPassword,
|
updateMailboxPassword,
|
||||||
setEmailForwardTargets,
|
|
||||||
getActiveProviderConfig,
|
|
||||||
} from './emailProvider/emailProviderService.js';
|
} from './emailProvider/emailProviderService.js';
|
||||||
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
@@ -115,8 +113,6 @@ export async function createEmail(data: CreateEmailData) {
|
|||||||
...emailData,
|
...emailData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasMailbox: true,
|
hasMailbox: true,
|
||||||
isProvisioned: true,
|
|
||||||
provisionedAt: new Date(),
|
|
||||||
emailPasswordEncrypted: passwordEncrypted,
|
emailPasswordEncrypted: passwordEncrypted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -135,11 +131,6 @@ export async function createEmail(data: CreateEmailData) {
|
|||||||
...emailData,
|
...emailData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasMailbox: createMailbox || false,
|
hasMailbox: createMailbox || false,
|
||||||
// Provisioned-Flag nur setzen wenn Provider-Aufruf gerade lief (oder
|
|
||||||
// die Mail bei Plesk schon existierte und der „existiert bereits"-Pfad
|
|
||||||
// gegriffen hat).
|
|
||||||
isProvisioned: !!provisionAtProvider,
|
|
||||||
provisionedAt: provisionAtProvider ? new Date() : null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -210,7 +201,7 @@ export async function syncMailboxStatus(id: number): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { email: true, hasMailbox: true, isProvisioned: true, provisionedAt: true },
|
select: { email: true, hasMailbox: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!stressfreiEmail) {
|
if (!stressfreiEmail) {
|
||||||
@@ -222,42 +213,19 @@ export async function syncMailboxStatus(id: number): Promise<{
|
|||||||
// Provider-Status prüfen
|
// Provider-Status prüfen
|
||||||
const providerStatus = await checkEmailExists(localPart);
|
const providerStatus = await checkEmailExists(localPart);
|
||||||
|
|
||||||
// Self-Healing für `isProvisioned`: das Flag wurde in einer früheren Code-
|
|
||||||
// Version beim Provisioning nie gesetzt → DB ist stellenweise inkonsistent
|
|
||||||
// zum Provider. Wir reconciliieren bei jedem Status-Sync mit.
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (!providerStatus.exists) {
|
if (!providerStatus.exists) {
|
||||||
// Beim Provider nicht (mehr) vorhanden → DB-Flag entsprechend
|
|
||||||
if (stressfreiEmail.isProvisioned) {
|
|
||||||
updates.isProvisioned = false;
|
|
||||||
}
|
|
||||||
if (stressfreiEmail.hasMailbox) {
|
|
||||||
updates.hasMailbox = false;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
|
|
||||||
return { success: true, hasMailbox: false, wasUpdated: true };
|
|
||||||
}
|
|
||||||
return { success: true, hasMailbox: false, wasUpdated: false };
|
return { success: true, hasMailbox: false, wasUpdated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beim Provider vorhanden → isProvisioned auf true ziehen falls noch nicht
|
|
||||||
if (!stressfreiEmail.isProvisioned) {
|
|
||||||
updates.isProvisioned = true;
|
|
||||||
if (!stressfreiEmail.provisionedAt) {
|
|
||||||
updates.provisionedAt = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerHasMailbox = providerStatus.hasMailbox === true;
|
const providerHasMailbox = providerStatus.hasMailbox === true;
|
||||||
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
|
|
||||||
updates.hasMailbox = providerHasMailbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
// DB aktualisieren wenn Status abweicht
|
||||||
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
|
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
|
||||||
console.log(`Stressfrei-Status für ${stressfreiEmail.email} reconciled:`, updates);
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { hasMailbox: providerHasMailbox },
|
||||||
|
});
|
||||||
|
console.log(`Mailbox-Status für ${stressfreiEmail.email} aktualisiert: ${stressfreiEmail.hasMailbox} -> ${providerHasMailbox}`);
|
||||||
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
|
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,120 +251,6 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weiterleitungen einer Stressfrei-Adresse neu setzen (z.B. nach Änderung der
|
|
||||||
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
|
|
||||||
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config].
|
|
||||||
//
|
|
||||||
// Wenn die Adresse `hasMailbox` ist: setzt zusätzlich das im CRM verschlüsselt
|
|
||||||
// hinterlegte Passwort am Provider neu (Use-Case: Plesk-Restore, manueller
|
|
||||||
// Eingriff im Plesk-UI etc. – CRM und Provider können sich entkoppeln, sodass
|
|
||||||
// IMAP/SMTP-Logins im CRM nicht mehr passen). Self-Healing.
|
|
||||||
//
|
|
||||||
// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein
|
|
||||||
// Duplikat-Risiko bei Mehrfachaufruf. Wenn die Operation erfolgreich war wird
|
|
||||||
// das `isProvisioned`-Flag automatisch auf `true` gezogen (historische
|
|
||||||
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
|
||||||
export async function syncForwardingForEmail(
|
|
||||||
id: number,
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
forwardTargets?: string[];
|
|
||||||
customerEmail?: string;
|
|
||||||
passwordReset?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
|
||||||
where: { id },
|
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
customerId: true,
|
|
||||||
isProvisioned: true,
|
|
||||||
hasMailbox: true,
|
|
||||||
emailPasswordEncrypted: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!stressfreiEmail) {
|
|
||||||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({
|
|
||||||
where: { id: stressfreiEmail.customerId },
|
|
||||||
select: { email: true },
|
|
||||||
});
|
|
||||||
if (!customer?.email) {
|
|
||||||
return { success: false, error: 'Kunde hat keine Stamm-E-Mail-Adresse hinterlegt' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await getActiveProviderConfig();
|
|
||||||
const forwardTargets: string[] = [customer.email];
|
|
||||||
if (config?.defaultForwardEmail) {
|
|
||||||
forwardTargets.push(config.defaultForwardEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
const localPart = stressfreiEmail.email.split('@')[0];
|
|
||||||
|
|
||||||
// 1) Forwards neu setzen.
|
|
||||||
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
|
||||||
if (!forwardResult.success) {
|
|
||||||
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
|
||||||
// statt der rohen Provider-Nachricht.
|
|
||||||
const err = forwardResult.error || 'Provider-Update fehlgeschlagen';
|
|
||||||
const friendly = /not\s*found|nicht\s*gefunden/i.test(err)
|
|
||||||
? 'E-Mail-Adresse beim Provider nicht gefunden – wurde sie dort gelöscht?'
|
|
||||||
: err;
|
|
||||||
return { success: false, error: friendly };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Wenn Mailbox: Passwort aus CRM-Speicher entschlüsseln und am Provider
|
|
||||||
// neu setzen (Self-Healing nach Provider-seitigen Änderungen).
|
|
||||||
let passwordReset = false;
|
|
||||||
if (stressfreiEmail.hasMailbox && stressfreiEmail.emailPasswordEncrypted) {
|
|
||||||
try {
|
|
||||||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
|
||||||
const pwResult = await updateMailboxPassword(localPart, password);
|
|
||||||
if (!pwResult.success) {
|
|
||||||
// Forwards waren schon erfolgreich – wir geben Forward-Erfolg + Passwort-
|
|
||||||
// Fehler kombiniert zurück, statt die ganze Operation rot zu machen.
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
forwardTargets,
|
|
||||||
customerEmail: customer.email,
|
|
||||||
error:
|
|
||||||
'Weiterleitungen aktualisiert, aber Passwort-Sync fehlgeschlagen: ' +
|
|
||||||
(pwResult.error || 'unbekannt'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
passwordReset = true;
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
forwardTargets,
|
|
||||||
customerEmail: customer.email,
|
|
||||||
error:
|
|
||||||
'Weiterleitungen aktualisiert, aber Passwort konnte nicht entschlüsselt werden – ' +
|
|
||||||
'evtl. wurde der ENCRYPTION_KEY rotiert',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Self-Healing: nach erfolgreichem Provider-Aufruf wissen wir definitiv,
|
|
||||||
// dass die Adresse beim Provider existiert → Flag korrigieren.
|
|
||||||
if (!stressfreiEmail.isProvisioned) {
|
|
||||||
await prisma.stressfreiEmail.update({
|
|
||||||
where: { id },
|
|
||||||
data: { isProvisioned: true, provisionedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
forwardTargets,
|
|
||||||
customerEmail: customer.email,
|
|
||||||
passwordReset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passwort neu generieren und beim Provider setzen
|
// Passwort neu generieren und beim Provider setzen
|
||||||
export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> {
|
export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
/**
|
|
||||||
* Access-Control-Helper für Portal-Kunden-Isolation.
|
|
||||||
*
|
|
||||||
* Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit
|
|
||||||
* sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über
|
|
||||||
* geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible
|
|
||||||
* Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf
|
|
||||||
* Besitz/Vollmacht geprüft werden.
|
|
||||||
*/
|
|
||||||
import { Response } from 'express';
|
|
||||||
import prisma from '../lib/prisma.js';
|
|
||||||
import * as authorizationService from '../services/authorization.service.js';
|
|
||||||
import { AuthRequest } from '../types/index.js';
|
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
|
|
||||||
* Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection.
|
|
||||||
*/
|
|
||||||
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
emitSecurityEvent({
|
|
||||||
type: 'ACCESS_DENIED',
|
|
||||||
severity: 'MEDIUM',
|
|
||||||
message: `Zugriff verweigert: ${label} #${targetId}`,
|
|
||||||
ipAddress: ctx.ipAddress,
|
|
||||||
userId: ctx.userId,
|
|
||||||
customerId: ctx.customerId,
|
|
||||||
userEmail: ctx.userEmail,
|
|
||||||
endpoint: ctx.endpoint,
|
|
||||||
details: { resource: label, targetId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
|
|
||||||
* - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer
|
|
||||||
* - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER
|
|
||||||
* wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht
|
|
||||||
*
|
|
||||||
* @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet)
|
|
||||||
*/
|
|
||||||
export async function canAccessContract(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
contractId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben
|
|
||||||
if (!req.user?.isCustomerPortal) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.user.customerId) {
|
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertrag laden, Besitzer-ID prüfen
|
|
||||||
const contract = await prisma.contract.findUnique({
|
|
||||||
where: { id: contractId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contract) {
|
|
||||||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eigene Verträge = immer erlaubt
|
|
||||||
if (contract.customerId === req.user.customerId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fremde Verträge nur mit aktiver Vollmacht
|
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
|
||||||
if (!representedIds.includes(contract.customerId)) {
|
|
||||||
emitAccessDenied(req, 'Contract', contractId);
|
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(
|
|
||||||
contract.customerId,
|
|
||||||
req.user.customerId,
|
|
||||||
);
|
|
||||||
if (!hasAuth) {
|
|
||||||
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
|
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft Zugriff auf einen Kunden (analog zu canAccessContract).
|
|
||||||
*/
|
|
||||||
export async function canAccessCustomer(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
customerId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.user.customerId) {
|
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customerId === req.user.customerId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
|
||||||
if (!representedIds.includes(customerId)) {
|
|
||||||
emitAccessDenied(req, 'Customer', customerId);
|
|
||||||
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
|
|
||||||
if (!hasAuth) {
|
|
||||||
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
|
|
||||||
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert die Liste aller Customer-IDs, auf die ein Portal-User aktuell
|
|
||||||
* Zugriff hat: eigene + vertretene MIT aktiver Vollmacht (Live-Check via
|
|
||||||
* `authorizationService.hasAuthorization`). Für Nicht-Portal-User wird
|
|
||||||
* `null` zurückgegeben (= kein Filter, alle Kunden erlaubt).
|
|
||||||
*
|
|
||||||
* Diese Funktion fängt einen wiederkehrenden Pentest-Befund ab: ohne den
|
|
||||||
* Live-Vollmacht-Check hätte ein Portal-User mit widerrufener Vollmacht
|
|
||||||
* weiterhin Zugriff auf die Daten des vertretenen Kunden, nur weil seine
|
|
||||||
* `representedCustomerIds` im JWT noch drin sind (Token kann bis zu
|
|
||||||
* 15min alt sein).
|
|
||||||
*/
|
|
||||||
export async function getPortalAllowedCustomerIds(
|
|
||||||
req: AuthRequest,
|
|
||||||
): Promise<number[] | null> {
|
|
||||||
if (!req.user?.isCustomerPortal || !req.user.customerId) return null;
|
|
||||||
const allowed: number[] = [req.user.customerId];
|
|
||||||
const represented: number[] = (req.user as any).representedCustomerIds || [];
|
|
||||||
for (const repCustId of represented) {
|
|
||||||
const hasAuth = await authorizationService.hasAuthorization(
|
|
||||||
repCustId,
|
|
||||||
req.user.customerId,
|
|
||||||
);
|
|
||||||
if (hasAuth) allowed.push(repCustId);
|
|
||||||
}
|
|
||||||
return allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generische Zugriffsprüfung: Ressource → customerId → canAccessCustomer.
|
|
||||||
*/
|
|
||||||
async function canAccessResourceByCustomerId(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
customerId: number | null | undefined,
|
|
||||||
resourceLabel: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
res.status(404).json({ success: false, error: `${resourceLabel} nicht gefunden` });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return canAccessCustomer(req, res, customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf eine Adresse prüfen (lädt sie aus der DB, prüft customerId).
|
|
||||||
*/
|
|
||||||
export async function canAccessAddress(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
addressId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const addr = await prisma.address.findUnique({
|
|
||||||
where: { id: addressId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(req, res, addr?.customerId, 'Adresse');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf eine BankCard prüfen.
|
|
||||||
*/
|
|
||||||
export async function canAccessBankCard(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
bankCardId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const card = await prisma.bankCard.findUnique({
|
|
||||||
where: { id: bankCardId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(req, res, card?.customerId, 'Bankkarte');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf ein IdentityDocument prüfen.
|
|
||||||
*/
|
|
||||||
export async function canAccessIdentityDocument(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
documentId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const doc = await prisma.identityDocument.findUnique({
|
|
||||||
where: { id: documentId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(req, res, doc?.customerId, 'Ausweis');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf einen Meter prüfen.
|
|
||||||
*/
|
|
||||||
export async function canAccessMeter(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
meterId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const meter = await prisma.meter.findUnique({
|
|
||||||
where: { id: meterId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(req, res, meter?.customerId, 'Zähler');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf eine StressfreiEmail prüfen.
|
|
||||||
*/
|
|
||||||
export async function canAccessStressfreiEmail(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
stressfreiEmailId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const sfe = await prisma.stressfreiEmail.findUnique({
|
|
||||||
where: { id: stressfreiEmailId },
|
|
||||||
select: { customerId: true },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(req, res, sfe?.customerId, 'E-Mail-Konto');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf eine CachedEmail prüfen (StressfreiEmail → customerId).
|
|
||||||
*/
|
|
||||||
export async function canAccessCachedEmail(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
emailId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const email = await prisma.cachedEmail.findUnique({
|
|
||||||
where: { id: emailId },
|
|
||||||
select: { stressfreiEmail: { select: { customerId: true } } },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
email?.stressfreiEmail?.customerId,
|
|
||||||
'E-Mail',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriff auf ein EnergyContractDetails prüfen (ECD → Contract → customerId).
|
|
||||||
*/
|
|
||||||
export async function canAccessEnergyContractDetails(
|
|
||||||
req: AuthRequest,
|
|
||||||
res: Response,
|
|
||||||
ecdId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!req.user?.isCustomerPortal) return true;
|
|
||||||
const ecd = await prisma.energyContractDetails.findUnique({
|
|
||||||
where: { id: ecdId },
|
|
||||||
select: { contract: { select: { customerId: true } } },
|
|
||||||
});
|
|
||||||
return canAccessResourceByCustomerId(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
ecd?.contract?.customerId,
|
|
||||||
'Energievertrag',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -88,43 +88,6 @@ export function generateSimplePassword(length = 12): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PASSWORD COMPLEXITY VALIDATION ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mindestanforderungen für vom User vergebene Passwörter.
|
|
||||||
* Generator-Output (generateSecurePassword) erfüllt diese standardmäßig.
|
|
||||||
*/
|
|
||||||
export interface PasswordComplexityResult {
|
|
||||||
ok: boolean;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validatePasswordComplexity(pw: unknown): PasswordComplexityResult {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (typeof pw !== 'string') {
|
|
||||||
return { ok: false, errors: ['Passwort fehlt oder ist kein Text'] };
|
|
||||||
}
|
|
||||||
if (pw.length < 12) errors.push('mindestens 12 Zeichen');
|
|
||||||
if (!/[a-z]/.test(pw)) errors.push('mindestens einen Kleinbuchstaben');
|
|
||||||
if (!/[A-Z]/.test(pw)) errors.push('mindestens einen Großbuchstaben');
|
|
||||||
if (!/[0-9]/.test(pw)) errors.push('mindestens eine Ziffer');
|
|
||||||
// Sonderzeichen-Set bewusst breit – auch Leerzeichen + Unicode-Punktuation
|
|
||||||
// zulassen, damit gängige Passwort-Manager-Outputs nicht abgelehnt werden.
|
|
||||||
if (!/[^A-Za-z0-9]/.test(pw)) errors.push('mindestens ein Sonderzeichen');
|
|
||||||
return { ok: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wirft mit sprechender Fehlermeldung, wenn das Passwort die Komplexität
|
|
||||||
* nicht erfüllt. Für Aufruf direkt im Controller, der die Exception fängt.
|
|
||||||
*/
|
|
||||||
export function assertPasswordComplexity(pw: unknown): void {
|
|
||||||
const r = validatePasswordComplexity(pw);
|
|
||||||
if (!r.ok) {
|
|
||||||
throw new Error('Passwort erfüllt Mindestanforderungen nicht: ' + r.errors.join(', '));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kryptografisch sichere Zufallszahl
|
// Kryptografisch sichere Zufallszahl
|
||||||
function getRandomInt(max: number): number {
|
function getRandomInt(max: number): number {
|
||||||
const bytes = randomBytes(4);
|
const bytes = randomBytes(4);
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie
|
|
||||||
* als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes,
|
|
||||||
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
|
||||||
const SENSITIVE_CUSTOMER_FIELDS = [
|
|
||||||
'portalPasswordHash',
|
|
||||||
'portalPasswordResetToken',
|
|
||||||
'portalPasswordResetExpiresAt',
|
|
||||||
// consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link
|
|
||||||
// (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer
|
|
||||||
// anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim
|
|
||||||
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
|
||||||
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
|
||||||
'consentHash',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
|
|
||||||
// sollen – Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
|
|
||||||
// unnötige Informationsleckage über den DB-Aufbau.
|
|
||||||
// Pentest Runde 7 (2026-05-17), MEDIUM.
|
|
||||||
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
|
|
||||||
'portalTokenInvalidatedAt',
|
|
||||||
'portalLastLogin',
|
|
||||||
'portalPasswordMustChange',
|
|
||||||
'lastBirthdayGreetingYear',
|
|
||||||
// privacyPolicyPath etc. sind interne Datei-Pfade – Portal nutzt
|
|
||||||
// dedizierte PDF-Endpoints, nicht den Pfad direkt
|
|
||||||
'privacyPolicyPath',
|
|
||||||
'businessRegistrationPath',
|
|
||||||
'commercialRegisterPath',
|
|
||||||
// Pentest Runde 10 (2026-05-17): notes sind interne CRM-Vermerke
|
|
||||||
// ("Kunde ist schwierig" etc.) und gehören nicht in die Portal-Sicht.
|
|
||||||
'notes',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
|
|
||||||
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
|
|
||||||
// (mit Audit-Log) abrufbar – im /contracts/:id selbst nutzlos.
|
|
||||||
const SENSITIVE_CONTRACT_FIELDS = [
|
|
||||||
'portalPasswordEncrypted',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
|
|
||||||
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
|
|
||||||
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
|
|
||||||
'commission',
|
|
||||||
'notes',
|
|
||||||
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const SENSITIVE_USER_FIELDS = [
|
|
||||||
'password',
|
|
||||||
'passwordResetToken',
|
|
||||||
'passwordResetExpiresAt',
|
|
||||||
'tokenInvalidatedAt',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
|
|
||||||
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
|
|
||||||
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
|
|
||||||
* es standardmäßig. Embedded `contracts[]` werden ebenfalls sanitisiert
|
|
||||||
* (Pentest Runde 10 – DTO-Leak in eingebetteten Objekten).
|
|
||||||
*/
|
|
||||||
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
|
|
||||||
if (!customer) return customer;
|
|
||||||
const copy: Record<string, unknown> = { ...customer };
|
|
||||||
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
|
||||||
delete copy[field];
|
|
||||||
}
|
|
||||||
if (Array.isArray(copy.contracts)) {
|
|
||||||
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
|
|
||||||
}
|
|
||||||
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
|
|
||||||
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
|
|
||||||
// auf 'customers:update' implizit gesichert.
|
|
||||||
return copy as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
|
|
||||||
* zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
|
|
||||||
* Admin ist (z.B. Portal-Kunde). Embedded `contracts[]` werden mit der
|
|
||||||
* Strict-Variante sanitisiert.
|
|
||||||
*/
|
|
||||||
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
|
|
||||||
if (!customer) return customer;
|
|
||||||
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
|
|
||||||
if (!copy) return null;
|
|
||||||
delete copy.portalPasswordEncrypted;
|
|
||||||
for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
|
|
||||||
delete copy[field];
|
|
||||||
}
|
|
||||||
if (Array.isArray(copy.contracts)) {
|
|
||||||
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContractStrict(c));
|
|
||||||
}
|
|
||||||
return copy as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize-Liste von Customers.
|
|
||||||
*/
|
|
||||||
export function sanitizeCustomers<T extends Record<string, unknown>>(customers: T[]): T[] {
|
|
||||||
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
|
|
||||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
|
||||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
|
||||||
*/
|
|
||||||
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
|
|
||||||
if (!contract) return contract;
|
|
||||||
const copy: Record<string, unknown> = { ...contract };
|
|
||||||
for (const field of SENSITIVE_CONTRACT_FIELDS) {
|
|
||||||
delete copy[field];
|
|
||||||
}
|
|
||||||
if (copy.customer && typeof copy.customer === 'object') {
|
|
||||||
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
return copy as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
|
|
||||||
* (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
|
|
||||||
* mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
|
|
||||||
*/
|
|
||||||
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
|
|
||||||
if (!contract) return contract;
|
|
||||||
const copy = sanitizeContract(contract) as Record<string, unknown> | null;
|
|
||||||
if (!copy) return null;
|
|
||||||
for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
|
|
||||||
delete copy[field];
|
|
||||||
}
|
|
||||||
if (copy.customer && typeof copy.customer === 'object') {
|
|
||||||
copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
return copy as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
|
|
||||||
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
|
|
||||||
return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize User-Objekt für API-Responses.
|
|
||||||
*/
|
|
||||||
export function sanitizeUser<T extends Record<string, unknown>>(user: T | null): T | null {
|
|
||||||
if (!user) return user;
|
|
||||||
const copy = { ...user };
|
|
||||||
for (const field of SENSITIVE_USER_FIELDS) {
|
|
||||||
delete copy[field];
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== REQUEST-BODY WHITELISTS ====================
|
|
||||||
// Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen.
|
|
||||||
|
|
||||||
const CUSTOMER_UPDATABLE_FIELDS = [
|
|
||||||
'type',
|
|
||||||
'salutation',
|
|
||||||
'useInformalAddress',
|
|
||||||
'firstName',
|
|
||||||
'lastName',
|
|
||||||
'companyName',
|
|
||||||
'foundingDate',
|
|
||||||
'birthDate',
|
|
||||||
'birthPlace',
|
|
||||||
'email',
|
|
||||||
'phone',
|
|
||||||
'mobile',
|
|
||||||
'taxNumber',
|
|
||||||
'commercialRegisterNumber',
|
|
||||||
'notes',
|
|
||||||
'portalEnabled',
|
|
||||||
'portalEmail',
|
|
||||||
'autoBirthdayGreeting',
|
|
||||||
'autoBirthdayChannel',
|
|
||||||
// Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken,
|
|
||||||
// portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash,
|
|
||||||
// lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const CUSTOMER_CREATE_FIELDS = [
|
|
||||||
...CUSTOMER_UPDATABLE_FIELDS,
|
|
||||||
// customerNumber wird vom Service generiert – nicht aus req.body übernehmen
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const USER_UPDATABLE_FIELDS = [
|
|
||||||
'email',
|
|
||||||
'firstName',
|
|
||||||
'lastName',
|
|
||||||
'isActive',
|
|
||||||
'whatsappNumber',
|
|
||||||
'telegramUsername',
|
|
||||||
'signalNumber',
|
|
||||||
'roleIds',
|
|
||||||
'password', // nur Admin, wird im Service gehashed
|
|
||||||
// hasGdprAccess + hasDeveloperAccess sind keine User-Spalten – der Service
|
|
||||||
// mappt sie auf die versteckten Rollen DSGVO/Developer (siehe
|
|
||||||
// setUserGdprAccess / setUserDeveloperAccess). Müssen aber auf der Whitelist
|
|
||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
|
||||||
'hasGdprAccess',
|
|
||||||
'hasDeveloperAccess',
|
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
|
||||||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
|
||||||
*/
|
|
||||||
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
|
|
||||||
const result: Partial<T> = {};
|
|
||||||
for (const key of allowed) {
|
|
||||||
if (key in obj) {
|
|
||||||
(result as any)[key] = (obj as any)[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
|
||||||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
|
||||||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
|
||||||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
|
||||||
return pick((body as object) || {}, USER_CREATE_FIELDS);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
|
|
||||||
* Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
|
|
||||||
*
|
|
||||||
* Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
|
|
||||||
* 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
|
|
||||||
* Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
|
|
||||||
* blockieren wir nur:
|
|
||||||
* - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
|
|
||||||
* - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
|
|
||||||
* - 0.0.0.0/8 (ungültiger Source/Routing-Range)
|
|
||||||
* - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
|
|
||||||
*
|
|
||||||
* Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
|
|
||||||
* Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es
|
|
||||||
* legitimes Caching/CDN-Verhalten brechen kann.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BLOCKED_PATTERNS: RegExp[] = [
|
|
||||||
/^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA)
|
|
||||||
/^0\./, // 0.0.0.0/8 reserved
|
|
||||||
/^22[4-9]\./, // 224-229 Multicast
|
|
||||||
/^23[0-9]\./, // 230-239 Multicast
|
|
||||||
/^24[0-9]\./, // 240-249 reserved
|
|
||||||
/^25[0-5]\./, // 250-255 reserved
|
|
||||||
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
|
||||||
/^fe80:/i, // IPv6 Link-Local
|
|
||||||
/^ff/i, // IPv6 Multicast
|
|
||||||
];
|
|
||||||
|
|
||||||
const BLOCKED_HOSTNAMES = new Set([
|
|
||||||
'metadata.google.internal',
|
|
||||||
'metadata.goog',
|
|
||||||
'metadata',
|
|
||||||
'169.254.169.254',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
|
||||||
if (!host) return false;
|
|
||||||
const h = host.trim().toLowerCase();
|
|
||||||
if (!h) return false;
|
|
||||||
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
|
||||||
for (const pattern of BLOCKED_PATTERNS) {
|
|
||||||
if (pattern.test(h)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
|
||||||
* Caller sollte den Fehler in 400er Response umsetzen.
|
|
||||||
*/
|
|
||||||
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
|
|
||||||
if (isBlockedSsrfHost(host)) {
|
|
||||||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import { promises as dns } from 'dns';
|
|
||||||
import net from 'net';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
|
|
||||||
* gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
|
|
||||||
*
|
|
||||||
* Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
|
|
||||||
* als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
|
|
||||||
* Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
|
|
||||||
* ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
|
|
||||||
*
|
|
||||||
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
|
|
||||||
*/
|
|
||||||
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
|
|
||||||
if (!host || !host.trim()) {
|
|
||||||
throw new Error(`${label} fehlt`);
|
|
||||||
}
|
|
||||||
const trimmed = host.trim();
|
|
||||||
|
|
||||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
|
||||||
if (net.isIP(trimmed)) {
|
|
||||||
assertAllowedHost(trimmed, label);
|
|
||||||
return { ip: trimmed, servername: trimmed };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hostname → resolve to IPv4 + IPv6
|
|
||||||
let ips: string[] = [];
|
|
||||||
try {
|
|
||||||
const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
|
|
||||||
const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
|
|
||||||
ips = [...v4, ...v6];
|
|
||||||
} catch {
|
|
||||||
throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ips.length === 0) {
|
|
||||||
throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
|
||||||
for (const ip of ips) {
|
|
||||||
if (isBlockedSsrfHost(ip)) {
|
|
||||||
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ip: ips[0], servername: trimmed };
|
|
||||||
}
|
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
# 📋 OpenCRM – Todo-Liste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔜 Offen
|
||||||
|
|
||||||
|
### Email Log & System testen
|
||||||
|
- Senden testen
|
||||||
|
- Empfangen testen
|
||||||
|
|
||||||
|
### Security System testen
|
||||||
|
|
||||||
|
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
|
||||||
|
|
||||||
|
**Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene
|
||||||
|
isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||||
|
über ein zentrales Admin-Portal.
|
||||||
|
|
||||||
|
**Architektur-Entscheidung:** Weg C (Instance-per-Customer)
|
||||||
|
- Pro Kunde eine eigene Docker-Instanz mit eigener DB
|
||||||
|
- Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter
|
||||||
|
- Komplette Datenisolation (DSGVO-freundlich)
|
||||||
|
- Updates können gestaffelt ausgerollt werden (erst 10% testen)
|
||||||
|
- Bei Kündigung: Docker-Image + DB-Export als "Mitnehm-Paket"
|
||||||
|
|
||||||
|
**Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration
|
||||||
|
(die wir schon haben) – Kunde bekommt Mail-Zugang über unseren Plesk bei Bedarf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Admin-Portal (separate App, neben den CRM-Instanzen):**
|
||||||
|
- Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled)
|
||||||
|
- "Neuen Kunden anlegen" → Provisioning-Script
|
||||||
|
- DB anlegen (Master-DB kennt die Mapping)
|
||||||
|
- Docker-Container starten
|
||||||
|
- Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik)
|
||||||
|
- Initial-Admin-Account erstellen + Einladungs-Email senden
|
||||||
|
- Optional: Factory-Defaults für Stammdaten einspielen
|
||||||
|
- GoCardless-Integration (Webhook + Dashboard)
|
||||||
|
- Instanz-Management: Pause/Resume bei Zahlungsproblemen
|
||||||
|
- Logs & Metriken pro Instanz (optional)
|
||||||
|
- Support-Bereich (Tickets? oder einfach E-Mail)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Abrechnung mit GoCardless (gocardless.com):**
|
||||||
|
- Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (über GoCardless Embedded/Success)
|
||||||
|
- 30 Tage kostenlose Testphase ohne Zahlungsmittel
|
||||||
|
- Nach Trial: Mandats-Erfassung → regelmäßige Abbuchung
|
||||||
|
- Mehrere Pläne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features
|
||||||
|
- Webhook-Endpoint im Admin-Portal:
|
||||||
|
- `payment_confirmed` → Instanz aktiv lassen
|
||||||
|
- `payment_failed` → Banner im CRM, nach X Tagen pausieren
|
||||||
|
- `mandate_cancelled` → Kündigungs-Flow
|
||||||
|
- Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen**
|
||||||
|
(mit USt-ID, Rechnungsnummer etc.) müssen wir selbst generieren
|
||||||
|
(evtl. über das existierende PDF-Template-System aus dem CRM nutzen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Provisioning-Flow (grober Entwurf):**
|
||||||
|
1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
|
||||||
|
2. Admin-Portal: Trial-Instanz starten
|
||||||
|
- DB erstellen, Docker-Container hochfahren, Caddy-Config für Subdomain
|
||||||
|
- Einladungs-Email mit Admin-Login + Passwort-Reset-Link
|
||||||
|
3. Tag 25: Erinnerungs-Email "Deine Trial läuft bald ab"
|
||||||
|
4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
|
||||||
|
5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
|
||||||
|
6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv
|
||||||
|
7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Technische Bausteine für später:**
|
||||||
|
- Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs)
|
||||||
|
- Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt)
|
||||||
|
- Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s
|
||||||
|
- Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups
|
||||||
|
- Monitoring: ein Fail macht nicht alle down, aber wir müssen es mitbekommen
|
||||||
|
- Logs zentral: z.B. Loki + Grafana für aggregierte Logs aller Instanzen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Grobe Zeitschätzung:**
|
||||||
|
- Admin-Portal (MVP): ~1 Woche
|
||||||
|
- GoCardless-Integration + Webhooks: ~3-5 Tage
|
||||||
|
- Provisioning-Automatisierung (Docker + Caddy): ~1 Woche
|
||||||
|
- Landing Page + Checkout: ~3-5 Tage
|
||||||
|
- Tests + Polishing: ~1 Woche
|
||||||
|
- **Gesamt: ~3-4 Wochen**
|
||||||
|
|
||||||
|
**Vorbereitung JETZT (einfach, macht später Arbeit leichter):**
|
||||||
|
- ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning)
|
||||||
|
- ✅ Domain/Label dynamisch per Provider (schon erledigt)
|
||||||
|
- Docker-Compose aufräumen, Env-Variablen dokumentieren (klein, ein Tag)
|
||||||
|
- Backup-Script robust + wiederherstellbar (haben wir schon weitgehend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
|
||||||
|
- Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
|
||||||
|
- Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
|
||||||
|
- Neuer Frontend-Hook `useProviderSettings()` liefert Domain + Label
|
||||||
|
- Alle hardcoded "Stressfrei-Wechseln" und `@stressfrei-wechseln.de` Strings durch dynamische Werte ersetzt
|
||||||
|
(CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
|
||||||
|
- Modal-Eingabefeld "Bezeichnung für Kunden-E-Mails" in Provider-Einstellungen
|
||||||
|
- Notwendig für Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird
|
||||||
|
|
||||||
|
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
|
||||||
|
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
|
||||||
|
- Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
|
||||||
|
- Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
|
||||||
|
- Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download über Browser
|
||||||
|
- Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
|
||||||
|
- Ordner `backend/factory-defaults/` gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
|
||||||
|
|
||||||
|
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
|
||||||
|
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
|
||||||
|
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher
|
||||||
|
2. **Als Vertragsdokument** – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
|
||||||
|
3. **Als Rechnung** – jetzt für **alle** Vertragstypen (vorher nur Strom/Gas)
|
||||||
|
- Gleiches gilt für das Speichern der gesamten Email als PDF-Rechnung
|
||||||
|
- Neuer Backend-Endpoint `saveAttachmentAsContractDocument` für die flexible ContractDocument-Tabelle
|
||||||
|
|
||||||
|
- [x] **Geburtstag-Management-Modal in Kundenstammdaten**
|
||||||
|
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
|
||||||
|
- **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback)
|
||||||
|
- **Gruß jetzt senden:** per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefülltes Fenster)
|
||||||
|
- Beide Aktionen mit Ja/Nein-Bestätigungsdialog (kein versehentliches Klicken)
|
||||||
|
- Text respektiert Du/Sie-Einstellung des Kunden
|
||||||
|
- Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer)
|
||||||
|
- Audit-Log für Reset + Send
|
||||||
|
|
||||||
|
- [x] **Anrede-Verhältnis Du/Sie pro Kunde**
|
||||||
|
- Neues Feld `useInformalAddress` in Stammdaten (auch bei Firmenkunden)
|
||||||
|
- Default: Sie (formell)
|
||||||
|
- Geburtstagsgruß im Portal nutzt die Anrede: "Du"-Kunden bekommen "Herzlichen Glückwunsch, Max!", "Sie"-Kunden "Herzlichen Glückwunsch, Herr Müller!"
|
||||||
|
- Komplett konsistent auch bei nachträglichen Glückwünschen ("hattest" vs "hatten")
|
||||||
|
|
||||||
|
- [x] **Geburtsdatum + Geburtsort auch bei Firmenkunden**
|
||||||
|
- Felder werden jetzt unabhängig vom Kundentyp angezeigt
|
||||||
|
- Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen
|
||||||
|
|
||||||
|
- [x] **Geburtstagskalender + Geburtstagsgruß-Modal**
|
||||||
|
- Admin: Section im Vertrags-Cockpit mit Kunden, die in den nächsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
|
||||||
|
- Portal: Modal mit Gruß am Geburtstag (inkl. nachträglichem Glückwunsch bis 7 Tage danach)
|
||||||
|
- Wird pro Jahr nur einmal angezeigt
|
||||||
|
|
||||||
|
- [x] **Typspezifische Zusatzinfos in Vertragslisten**
|
||||||
|
- Strom/Gas → "Lieferadresse: ..."
|
||||||
|
- DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
|
||||||
|
- Mobilfunk → "Rufnummer: ..."
|
||||||
|
- KFZ → "Kennzeichen: ..."
|
||||||
|
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
|
||||||
|
|
||||||
|
- [x] **Datenschutzerklärung PDF ↔ Online-Einwilligungen synchronisieren**
|
||||||
|
- PDF hochgeladen → alle 4 Consents auf GRANTED
|
||||||
|
- Haken entfernt im Portal → PDF löschen + Tabs sperren
|
||||||
|
- Entsperrung nur durch alle Haken oder neues PDF
|
||||||
|
|
||||||
|
- [x] **Zweitarif-Zähler (HT/NT)** bei Strom + Verbrauchsberechnung
|
||||||
|
|
||||||
|
- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)
|
||||||
|
|
||||||
|
- [x] **Audit-Log aussagekräftig** (Vorher/Nachher bei allen Änderungen)
|
||||||
|
|
||||||
|
- [x] **Impressum + Website-Datenschutzerklärung** im Kundenportal
|
||||||
|
- Editor in Einstellungen
|
||||||
|
- Vorschlagstexte
|
||||||
|
|
||||||
|
- [x] **Consent-Bestätigungs-Flow per Email**
|
||||||
|
- Alle Hebel müssen gesetzt sein
|
||||||
|
- Bestätigungsbutton + Bestätigungsemail
|
||||||
|
|
||||||
|
- [x] **Vertragsdokumente-Upload** (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)
|
||||||
|
|
||||||
|
- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)
|
||||||
|
|
||||||
|
- [x] **PDF-Auftragsvorlagen-System**
|
||||||
|
- Template-Editor in Einstellungen
|
||||||
|
- PDF hochladen, Formularfelder automatisch auslesen
|
||||||
|
- CRM-Felder zuordnen (visuell mit Vorschau)
|
||||||
|
- Seitenweise Sortierung der Felder
|
||||||
|
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
|
||||||
|
- Nicht zugeordnete Felder bleiben editierbar
|
||||||
|
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
|
||||||
|
|
||||||
|
- [x] **Eigentümer-Verwaltung**
|
||||||
|
- An Adresse gehängt (Firma, Vorname, Nachname, Anschrift, Kontakt)
|
||||||
|
- Fallback auf Kundendaten wenn leer
|
||||||
|
- Nur bei Liefer-/Meldeadressen (nicht Rechnung)
|
||||||
|
- Namens-Kombinationen (Firma + Vorname + Nachname etc.)
|
||||||
|
|
||||||
|
- [x] **Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse** im Auftragsgenerator
|
||||||
|
|
||||||
|
- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)
|
||||||
|
|
||||||
|
- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user