Compare commits

..

43 Commits

Author SHA1 Message Date
duffyduck ea3f3c6d29 chore: Build-Artefakte und node_modules aus Tracking entfernen
- backend/dist, backend/node_modules aus Git-Tracking entfernt (waren bereits in .gitignore)
- frontend/dist, frontend/node_modules ebenfalls entfernt
- Neue frontend/.gitignore erstellt (fehlte komplett)

Die Dateien bleiben lokal erhalten und werden durch npm install + build regeneriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:09 +02:00
duffyduck d1e78b4b8e todo: Geburtstagskalender als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:48 +02:00
duffyduck 9e55e25dc8 Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal
Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:20 +02:00
duffyduck a47dfcd841 todo: Vertragslisten-Erweiterung als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:20:01 +02:00
duffyduck f17adb6095 Typspezifische Zusatzinfos in Vertragslisten
Jede Vertragszeile zeigt jetzt eine kontextspezifische Zusatzinfo an:
- Strom/Gas: "Lieferadresse: Musterstr. 12, 12345 Berlin"
- DSL/Glasfaser/Kabel: "Anschlussadresse: ..."
- Mobilfunk: "Rufnummer: 0171 1234567" (Hauptkarte bevorzugt)
- KFZ: "Kennzeichen: HB-AB 123"

Sichtbar in:
- Admin-Vertragsliste (/contracts)
- Portal-Vertragsliste (Baumansicht)
- Kunden-Detail → Verträge-Tab

Backend: getAllContracts + getContractTreeForCustomer liefern
mobileDetails (mit simCards), carInsuranceDetails und address mit.

Frontend: Neuer Helper utils/contractInfo.ts mit getContractTypeInfo,
aus dem sowohl Label als auch Wert pro Typ kommt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:04 +02:00
duffyduck 50b0e56a84 update todo.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:18:33 +02:00
duffyduck 29eceef26b PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung
- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00
duffyduck 5e9e553882 delete test pdf 2026-03-27 12:12:22 +01:00
duffyduck 5b85bea4eb email datenschztz erst alles bestätigt bei allen hebeln 2026-03-27 12:03:20 +01:00
duffyduck 3dd4f7b656 added place to telecommunication, added contract documents, added invoice to other contracts 2026-03-25 16:55:48 +01:00
duffyduck eaa94e766a impressum datenschutz added 2026-03-25 15:25:34 +01:00
duffyduck 219e1930f7 complete new audit system 2026-03-21 18:23:54 +01:00
duffyduck 4f359df161 Datenschutz vollmacht fixed, two time counter added 2026-03-21 16:42:31 +01:00
duffyduck 0121c82412 fixed back button with source, and customer in customer lsit clickable 2026-03-21 12:16:04 +01:00
duffyduck a9643206bb fixed all back buttons 2026-03-21 12:03:32 +01:00
duffyduck f2876f877e gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery 2026-03-21 11:59:53 +01:00
duffyduck 89cf92eaf5 added docker setup 2026-02-08 19:59:49 +01:00
duffyduck dd4d57fa1b added recovery entires, changed recovery icon 2026-02-08 19:43:46 +01:00
duffyduck e348e86c60 added contract history 2026-02-08 19:24:37 +01:00
duffyduck ee4f1aacdd added date at support ticket, new order support tickets, delete edit support ticktes only from enploye and admins 2026-02-08 18:26:34 +01:00
duffyduck 06489299d5 contractnumber provider added, old provider number field only if no previous contact exist 2026-02-08 14:34:56 +01:00
duffyduck 4442ab08b3 readme updated 2026-02-08 13:14:24 +01:00
duffyduck efe8ac25cb snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug 2026-02-08 13:08:58 +01:00
duffyduck 3a9fcc5ec9 fixed issue stressfrei adress as username not filled oin cockpit 2026-02-08 09:09:00 +01:00
duffyduck d400c90e6a updated readme.md 2026-02-08 01:21:00 +01:00
duffyduck aee48a8ccb added invoices and status in cockpit, created info button for contract status types 2026-02-08 01:18:12 +01:00
duffyduck 1ad4fe0819 addes cost and usage calculation 2026-02-06 00:14:38 +01:00
duffyduck b281801cdb added extra field kwh at m3, expand cost field to 10 komma, added maloid,counter add dialog, auto set unit 2026-02-05 20:34:45 +01:00
duffyduck af2f444a24 contractmodaldetail date format and before contract and next contract question to add 2026-02-04 21:17:13 +01:00
duffyduck 2d052c76d9 save email as pdf likae attachment version 2 2026-02-04 19:49:09 +01:00
duffyduck d98c97a81f remove uploads from repo but keep empty folder 2026-02-04 19:19:18 +01:00
duffyduck 06d45734ce save email as pdf like an attachment 2026-02-04 19:18:32 +01:00
duffyduck b968e6b46d added tree view to customer portal, in employe its uses still list 2026-02-04 16:30:49 +01:00
duffyduck 312e879221 seperate delivery and billig adresses in contract added 2026-02-04 08:48:25 +01:00
duffyduck fdef6d1d3b fixed, bankcard, adresses, id card, tarif name dropdown menu in edit mode 2026-02-04 08:37:46 +01:00
duffyduck 2b23ed64c4 added new view in contracts customer and contracts 2026-02-04 00:52:04 +01:00
duffyduck 97b4670643 save attachment from email in customer data and - or contracts 2026-02-03 23:58:00 +01:00
duffyduck 9a014c100b update readme.md 2026-02-03 23:08:08 +01:00
duffyduck 6f3ab288ed all email views the same 2026-02-03 23:04:42 +01:00
duffyduck ee8bd7a8f7 optimize password view in stressfrei addresses 2026-02-03 15:43:00 +01:00
duffyduck e4fdfbc95f added backup and email client 2026-02-01 00:02:35 +01:00
duffyduck ff857be01a imapclient feature plan 2026-01-29 01:34:43 +01:00
Stefan Hacker 31f807fbd0 first commit 2026-01-29 01:16:54 +01:00
135 changed files with 1801 additions and 11388 deletions
-9
View File
@@ -46,15 +46,6 @@ backups
backend/uploads
backend/backups
# Daten-Verzeichnis (Bind-Mounts zur Laufzeit, nicht im Build-Context)
data/
# Plesk-Test (nicht für Container)
plesktest/
# Backup-Klone des Repos
opencrm-backup-*/
# Prisma migrations (included, but not dev db)
*.db
*.db-journal
-76
View File
@@ -1,76 +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
JWT_SECRET=change-this-to-a-very-long-random-secret-please-rotate-before-production
JWT_EXPIRES_IN=7d
# Encryption-Key für Portal-Credentials: GENAU 64 Hex-Zeichen.
# Generieren: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
# Server
NODE_ENV=development
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127.0.0.1
# CORS nur in Production setzen, wenn Frontend auf separater Domain läuft.
# Beispiel: CORS_ORIGINS=https://crm.deine-domain.de
# CORS_ORIGINS=
# HTTPS-only-Header (HSTS + upgrade-insecure-requests) NUR aktivieren, wenn
# wirklich ein TLS-Proxy (Caddy/Traefik/Nginx) vor OpenCRM steht. Sonst sperrt
# sich der Browser bei direktem http://ip:port-Zugriff selbst aus
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
HTTPS_ENABLED=false
# ============== ADMINER (DB-UI) ==============
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
# haeckel, hever, konya, lavender-light, lucas-sandery, mancave,
# mvt, nette, ng9, nicu, pappu687, paranoiq, pepa-linha, pokorny,
# price, rmsoft, rmsoft_blue, rmsoft_blue-dark, win98
# Empfehlung: dracula (dark) oder adminer-dark beide modern.
ADMINER_DESIGN=dracula
# ============== SEED ==============
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
# + Stammdaten an) nichts zu konfigurieren.
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
# wieder zurück.
RUN_SEED=false
-41
View File
@@ -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
+13 -408
View File
@@ -2,8 +2,6 @@
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
**Version: 1.1.0** ([Changelog](#changelog))
## Features
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
@@ -13,9 +11,6 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT``ACTIVE` (mit Vertragsbeginn),
Kündigungsbestätigung-Upload setzt `ACTIVE``CANCELLED` (mit Datum),
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
- **Verträge**:
- Energie (Strom, Gas)
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
@@ -25,14 +20,7 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
Sofort-E-Mail-Alerts und stündlichem Digest siehe Einstellungen → Monitoring
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
## Tech Stack
@@ -47,62 +35,32 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
> - Express 4.x → `@types/express@^4.17.x`
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
## Quick-Start mit Docker (empfohlen)
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Werte anpassen, Secrets rotieren!
docker compose up -d
```
Browser:
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
Alle persistenten Daten liegen in `./data/`:
| Pfad | Inhalt |
|------|--------|
| `./data/db/` | MariaDB-Datafiles |
| `./data/uploads/` | User-Uploads (PDFs, Bilder) |
| `./data/factory-defaults/` | Stammdaten-Kataloge |
| `./data/backups/` | DB-Backups (`npm run db:backup`) |
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
> der initiale Admin-User `admin@admin.com` / `admin`.
## Voraussetzungen
- Docker & Docker Compose v2
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
- Node.js 18+ (empfohlen: 20+)
- Docker & Docker Compose
- npm
## Installation für Entwicklung (ohne Container)
## Installation
### 1. Repository klonen
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Konfiguration anpassen
```
### 2. MariaDB-Container starten
### 2. MariaDB-Datenbank starten
```bash
docker compose up -d db
docker-compose up -d
```
Das startet nur die Datenbank (mit Daten in `./data/db/`).
Konfiguration kommt aus `./.env`:
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
Dies startet einen MariaDB-Container mit:
- **Port:** 3306
- **Datenbank:** opencrm
- **Root-Passwort:** rootpassword
- **Benutzer:** opencrm / opencrm123
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
@@ -182,39 +140,6 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** admin@admin.com
- **Passwort:** admin
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
> ändern und Secrets rotieren siehe [Production-Deployment](#production-deployment).
## Production-Deployment
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
```env
NODE_ENV=production
# Pflicht-Rotation per `openssl rand` neu generieren!
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
```
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
hinterlegen, Test-Alert senden, Digest aktivieren.
- Vollständige Hardening-Story + restliche Trade-offs:
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
## Developer-Tools aktivieren
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
@@ -245,84 +170,12 @@ Das System unterstützt die automatische Erstellung von E-Mail-Weiterleitungen a
- **Name**: Bezeichnung (z.B. "Plesk Hauptserver")
- **Typ**: Plesk/cPanel/DirectAdmin
- **API-URL**: Server-URL (z.B. `https://server.de:8443`)
- **API-Key** _(empfohlen bei Plesk)_: Key aus Plesk (siehe unten), alternativ Benutzername/Passwort
- **Benutzername/Passwort**: Nur wenn kein API-Key vorhanden
- **Benutzername/Passwort**: API-Zugangsdaten
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
3. Provider als "Standard" und "Aktiv" markieren
4. Verbindung testen
### Plesk: API-Key anlegen
Der API-Key ist die empfohlene Authentifizierungsmethode (sicherer als Passwort, kann pro
Anwendung vergeben und widerrufen werden).
**Variante 1: Über die Plesk-Oberfläche (einfachster Weg)**
1. In Plesk als Admin einloggen
2. Oben rechts auf den **eigenen Namen** → **"Mein Profil"** (oder direkt URL `/admin/my-profile/`)
3. Tab **"API-Token"** oder **"API-Schlüssel"** öffnen
4. **"API-Schlüssel erstellen"** (bzw. "Add API Key")
5. Beschreibung vergeben (z.B. "OpenCRM")
6. Den angezeigten Schlüssel **sofort kopieren** er wird nur einmal angezeigt!
7. Im CRM bei "API-Key" einfügen
> **Hinweis:** Bei manchen Plesk-Versionen ist die Option unter
> **Tools & Einstellungen** → **API-Schlüssel** oder **Werkzeuge & Einstellungen** →
> **API-Tokens** zu finden. Wenn der Menüpunkt fehlt, muss ggf. die **REST API**
> Extension installiert werden (siehe Variante 2).
**Variante 2: Über die Kommandozeile (SSH als root)**
Falls der API-Key-Button in Plesk nicht vorhanden ist, lässt er sich auch per SSH erstellen:
```bash
# API-Key generieren (läuft nicht ab)
# WICHTIG: -ip-address weglassen, wenn der Key von beliebigen IPs genutzt werden soll!
plesk bin secret_key --create -description "OpenCRM"
# Alternativ mit IP-Einschränkung (nur Zugriffe von dieser IP sind erlaubt):
plesk bin secret_key --create -ip-address <IP-DES-CRM-SERVERS> -description "OpenCRM"
```
> **Achtung:** `-ip-address 0.0.0.0` funktioniert **nicht** wie bei anderen Tools!
> Plesk prüft exakt gegen die eingetragene IP. Für "alle IPs erlauben" muss der
> `-ip-address`-Parameter komplett weggelassen werden.
Der Befehl gibt den Key direkt zurück. Diesen kopieren und im CRM eintragen.
**Alle API-Keys anzeigen:**
```bash
plesk bin secret_key --list
```
**API-Key löschen:**
```bash
plesk bin secret_key --delete <KEY>
```
### Plesk: REST API aktivieren (falls nicht vorhanden)
Bei älteren Plesk-Versionen oder Custom-Installationen kann es sein, dass die
REST API fehlt. Dann:
1. **Tools & Einstellungen** → **Updates** → **Erweiterungen hinzufügen/entfernen**
2. Nach **"REST API"** suchen und installieren
3. Plesk-Neustart (meist nicht nötig, aber zur Sicherheit)
### Plesk: Firewall-Hinweis
Der CRM-Server muss den **Plesk-Port 8443** (Standard) erreichen können. Bei Plesk-Firewall:
1. **Tools & Einstellungen** → **Firewall**
2. **"Plesk-Dienst Panel"** (Port 8443) für die IP des CRM-Servers erlauben
Bei reiner Linux-Firewall (ufw/firewalld):
```bash
# Beispiel ufw
ufw allow from <CRM-SERVER-IP> to any port 8443
```
### Verwendung
Beim Anlegen einer Stressfrei-Wechseln Adresse im Kundenbereich erscheint die Checkbox **"Beim E-Mail-Provider anlegen"**, wenn:
@@ -1063,254 +916,6 @@ Folgende Felder werden in Audit-Logs gefiltert:
- API-Response wird nicht blockiert
- Before/After-Werte über Prisma Middleware
## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
zu Datenbank-Backups:
### Abgrenzung
| | Factory-Defaults | Datenbank-Backup |
|---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
> **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
> Backup = die komplette Instanz.
### Drei Wege, eine ZIP zu transportieren
Es gibt drei Pfade, je nachdem wo die ZIP gerade liegen soll:
| Wo | Pfad | Wann |
|---|---|---|
| **Laufende DB einer Instanz** | UI-Upload oder `./factory-import.sh` | Bestehende Live-Instanz updaten |
| **Drop-Box im Repo** (`factory-exports/`) | `./factory-export.sh` legt ab, `./factory-import.sh` liest | Transfer zwischen dev und prod via `scp` |
| **Werkseinstellung im Image** (`backend/factory-defaults/`) | `./factory-import.sh --save-as-builtin` oder manuell entpacken | Neue VMs sollen die Defaults beim allerersten Start mitbringen |
Alle drei sind unabhängig, **alle drei zusammen** decken den typischen Workflow ab.
### Export
**Variante A UI:**
1. **Einstellungen** → **Factory-Defaults** öffnen
2. Button **„Factory-Defaults exportieren"** klicken
3. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
**Variante B CLI (für scp-Transfers):**
```bash
./factory-export.sh # → factory-exports/factory-defaults-…zip
OPENCRM_URL=https://crm.prod.example.de \
OPENCRM_EMAIL=admin@example.de ./factory-export.sh # gegen Prod-Instanz
```
Ohne `OPENCRM_PASSWORD` wird das Passwort interaktiv abgefragt. Der Zielordner
`factory-exports/` ist gitignored die ZIPs landen also nicht ins Repo.
**ZIP-Struktur:**
```
factory-defaults-2026-05-07-1949.zip
├── manifest.json # Version + Datum + Counts
├── providers/providers.json
├── contract-meta/
│ ├── cancellation-periods.json
│ ├── contract-durations.json
│ └── contract-categories.json
├── pdf-templates/
│ ├── pdf-templates.json
│ └── *.pdf # Die eigentlichen PDF-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates (Whitelist-only)
```
### Import
**Variante A UI:**
1. **Einstellungen** → **Factory-Defaults**
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
3. Erfolgs-Box zeigt Counts pro Kategorie
**Variante B CLI:**
```bash
./factory-import.sh # nimmt jüngste ZIP aus factory-exports/
./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
./factory-import.sh --save-as-builtin # zusätzlich ins Image-Default
./factory-import.sh --save-as-builtin ./foo.zip # entpacken (siehe unten)
```
Konfigurierbar per ENV: `OPENCRM_URL`, `OPENCRM_EMAIL`, `OPENCRM_PASSWORD`.
**Variante C Container-Bare-Metal (für Migration / mehrere ZIPs zusammenführen):**
```bash
# Inhalt der ZIP nach backend/factory-defaults/ entpacken (Unterordner beibehalten)
cd backend && npm run seed:defaults
```
**Beispiel-Output:**
```
✓ Anbieter: 10
✓ Tarife: 4
✓ Kündigungsfristen: 18
✓ Laufzeiten: 18
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 2
✓ HTML-Templates: 2
```
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
DB-Import** zusätzlich in `backend/factory-defaults/`. Beim nächsten
`docker-compose up --build` landen die Defaults im Image. Frisch hochgezogene
VMs bringen sie dann beim ersten Start automatisch mit (Auto-Seed-Pfad im
Container-Entrypoint).
```bash
# typischer Sync prod → dev → Image-Default
ssh prod './factory-export.sh'
scp prod:opencrm/factory-exports/factory-defaults-*.zip factory-exports/
./factory-import.sh --save-as-builtin
docker-compose up -d --build # neuer Build, neue VMs starten direkt mit Defaults
```
Der Inhalt von `backend/factory-defaults/` wird beim `--save-as-builtin` vorher
geleert (außer `README.md` und `.gitkeep`), damit nichts Veraltetes liegen
bleibt.
### Mehrere ZIPs kombinieren (CLI-only, Variante C)
`backend/factory-defaults/` darf mehrere `*.json` pro Unterordner haben
`npm run seed:defaults` merged sie automatisch:
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus Verivox-Paket
check24.json # 30 Anbieter aus Check24-Paket
eigene.json # 5 eigene Anbieter
```
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
nimmt nur eine ZIP entgegen für Merges nutze `npm run seed:defaults`.
### Idempotenz
Alle Pfade nutzen Prisma `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
- Nichts wird gelöscht
Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
oder Duplikate.
### PDF-Dateien
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
die neue ersetzt.
### AppSettings-Whitelist
Beim Import werden nur die Keys mit AppSetting-Schreibzugriff gewährt, die auch
exportiert werden aktuell:
- `privacyPolicyHtml`
- `imprintHtml`
- `authorizationTemplateHtml`
- `websitePrivacyPolicyHtml`
Andere Keys (SMTP, JWT, etc.) werden mit einer Warnung ignoriert. Whitelist ist
in [`backend/src/services/factoryDefaults.service.ts`](backend/src/services/factoryDefaults.service.ts)
zentral gepflegt.
### Auto-Seed beim Erst-Deploy
Bei einer **frischen** Installation (leere DB) spielt der Container-Entrypoint
nach dem Prisma-Seed automatisch das Built-in-Verzeichnis ein:
```
[entrypoint] DB ist leer (User-Count=0) Auto-Seed wird ausgeführt
[entrypoint] Spiele eingebaute Factory-Defaults ein…
✓ Anbieter: 10, Tarife: 4
```
Bei bestehenden Installs passiert das **nicht** nur frische DBs.
### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI/CLI) | `settings:update` |
| Factory-Defaults Import (UI/CLI) | `settings:update` |
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
### Typische Einsatzzwecke
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
(oder per `--save-as-builtin`), dann `docker-compose up --build` die
Werkseinstellungen sind beim ersten Start automatisch drin.
- **Prod-Stand zurück nach dev synchronisieren**: `./factory-export.sh` auf prod,
`scp` ins dev, `./factory-import.sh --save-as-builtin` lokal damit ist
sowohl die dev-DB aktuell als auch der nächste Image-Build.
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
(andere Ordner aus der ZIP entfernen vor dem Entpacken).
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
## Changelog
### 1.1.0 (2026-05-01)
**Production-readiness** die Version, die wirklich öffentlich gehen darf.
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
- 🔄 **Auto-Vertragsstatus**:
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
(mit Datums-Modal beim Upload)
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
Login transparent auf Cost 12 geupgradet.
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
über `tokenInvalidatedAt`.
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
path-to-regexp, undici, minimatch).
### 1.0.0
Erste Release-Version.
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
- Vertrags-Cockpit mit Rechnungsprüfung
- E-Mail-Client mit Anhang-Verwaltung
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
- Factory-Defaults für Stammdaten-Kataloge
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
## Lizenz
MIT
+13
View File
@@ -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
-6
View File
@@ -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_URL="mysql://user:password@localhost:3306/opencrm"
+1 -7
View File
@@ -4,11 +4,10 @@ node_modules/
# Build
dist/
# Environment echte Secrets blocken, .env.example weiter mittracken
# Environment
.env
.env.local
.env.*.local
!.env.example
# Database Backups (can be large, keep folder structure)
prisma/backups/*
@@ -18,11 +17,6 @@ prisma/backups/*
uploads/*
!uploads/.gitkeep
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
factory-defaults/*
!factory-defaults/.gitkeep
!factory-defaults/README.md
# Logs
*.log
npm-debug.log*
-71
View File
@@ -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"]
-126
View File
@@ -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 "$@"
View File
-290
View File
@@ -1,290 +0,0 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
Siehe auch den Abschnitt „Factory-Defaults" in der Haupt-[README.md](../../README.md)
für einen Gesamtüberblick und die Abgrenzung zum Datenbank-Backup.
---
## Inhalt
```
backend/factory-defaults/
├── providers/
│ └── providers.json # Anbieter inkl. Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen
│ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
├── pdf-templates/
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
│ └── *.pdf # PDF-Vorlagen-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
# Vollmacht / Website-Datenschutz
```
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
Standardpflichten) andere Keys werden beim Import ignoriert.
---
## Export (aus einer bestehenden Installation)
1. Im CRM als Admin einloggen
2. **Einstellungen****Factory-Defaults**
3. Auf **„Factory-Defaults exportieren"** klicken
4. Die heruntergeladene ZIP (`factory-defaults-YYYY-MM-DD.zip`) speichern
### Inhalt der ZIP
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version, Datum, Einträge pro Kategorie
├── providers/providers.json
├── contract-meta/cancellation-periods.json
├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.json
├── pdf-templates/pdf-templates.json
├── pdf-templates/*.pdf
└── app-settings/app-settings.json
```
Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme,
neue Installationen oder Partner-Setups.
---
## Import (in eine andere Installation)
### Variante A: Über die UI (empfohlen)
1. Im Ziel-CRM als Admin einloggen
2. **Einstellungen → Factory-Defaults**
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
4. Die exportierte ZIP wählen der Import läuft direkt
5. Erfolgsmeldung zeigt Counts pro Kategorie an
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
Unterordnerstruktur beibehalten
3. **Script ausführen:**
```bash
cd backend
npm run seed:defaults
```
4. **Ausgabe prüfen** bei Erfolg:
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Idempotenz
Das Script nutzt ausschließlich `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (match per unique key: `name` / `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` **beliebig oft ausführen** kein Datenverlust,
keine Duplikate.
### Was passiert mit den PDF-Dateien?
Die PDFs aus `pdf-templates/*.pdf` werden beim Import nach
`backend/uploads/pdf-templates/` kopiert (mit eindeutigem Zeitstempel-Suffix).
Die Pfade in der DB werden automatisch auf die neue Kopie gesetzt.
Beim Re-Import einer bereits existierenden Vorlage wird die alte Datei in `uploads/`
entsorgt und durch die neue ersetzt.
---
## Mehrere Exporte mergen
Wenn du mehrere ZIPs hast (z.B. "Verivox-Paket", "Check24-Paket", "eigene"), kannst
du die JSON-Dateien frei benennen und in einen Ordner legen. Das Script liest
alle `*.json` im jeweiligen Unterordner und merged den Inhalt zusammen.
**Beispiel:**
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus dem Verivox-Paket
check24.json # 30 Anbieter aus dem Check24-Paket
eigene.json # 5 eigene, firmenspezifische Anbieter
contract-meta/
standard.json # Standard-Kündigungsfristen + Laufzeiten + Kategorien
pdf-templates/
ewe-paket.json # EWE-Vorlage
moon-paket.json # Moon-Vorlage
ewe-auftrag.pdf
moon-formular.pdf
```
Bei gleichem Unique-Key (z.B. `providers.name: "EWE"` in mehreren Dateien) gewinnt
der zuletzt gelesene Eintrag.
---
## Teil-Import (nur Kategorien auswählen)
Falls du nur einen Teil importieren willst (z.B. nur PDF-Vorlagen ohne Anbieter),
lösche oder verschiebe einfach die nicht gewünschten JSON-Dateien, bevor du das
Script ausführst. Das Script überspringt Kategorien ohne Dateien ohne Fehler.
**Beispiel: nur PDF-Vorlagen:**
```
backend/factory-defaults/
pdf-templates/ # nur diesen Ordner behalten
pdf-templates.json
*.pdf
```
---
## Struktur-Referenz (für manuelle Pflege)
### `providers/providers.json`
Array von Providern, jeweils inkl. zugehöriger Tarife:
```json
[
{
"name": "EWE",
"portalUrl": "https://www.ewe.de/privatkunden/meine-ewe/login",
"usernameFieldName": "username",
"passwordFieldName": "password",
"isActive": true,
"tariffs": [
{ "name": "EWE Zuhause Strom", "isActive": true },
{ "name": "EWE Zuhause Gas", "isActive": true }
]
}
]
```
**Unique Key:** `name`
### `contract-meta/cancellation-periods.json`
```json
[
{ "code": "14T", "description": "14 Tage", "isActive": true },
{ "code": "1M", "description": "1 Monat", "isActive": true },
{ "code": "3M", "description": "3 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-durations.json`
```json
[
{ "code": "12M", "description": "12 Monate", "isActive": true },
{ "code": "24M", "description": "24 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-categories.json`
```json
[
{
"code": "ELECTRICITY",
"name": "Strom",
"icon": "Zap",
"color": "#FFC107",
"sortOrder": 1,
"isActive": true
}
]
```
**Unique Key:** `code`
### `pdf-templates/pdf-templates.json`
```json
[
{
"name": "EWE Auftragsformular",
"description": "Auftrag für Glasfaser-Anschluss",
"providerName": "EWE",
"originalName": "EWE-Auftrag-Privat.pdf",
"fieldMapping": {
"Vorname": "customer.firstName",
"Nachname": "customer.lastName",
"Strasse": "address.streetFull",
"PLZ": "address.postalCode",
"Ort": "address.city"
},
"phoneFieldPrefix": "Rufnummer",
"maxPhoneFields": 8,
"isActive": true,
"pdfFilename": "EWE_Auftragsformular.pdf"
}
]
```
**Unique Key:** `name`
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
### `app-settings/app-settings.json`
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv andere Keys
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
```json
[
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
]
```
**Unique Key:** `key`
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
`websitePrivacyPolicyHtml`.
---
## Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
---
## Git & Versionierung
Dieser Ordner ist in `.gitignore` eingetragen (außer `.gitkeep` und `README.md`),
damit firmen-spezifische Exporte nicht versehentlich ins Repo kommen.
Wenn du **öffentlich teilbare Katalog-Pakete** versionieren willst, lege sie
außerhalb dieses Ordners ab (z.B. in einem eigenen Repository) und kopiere sie
bei Bedarf hierher.
+83 -213
View File
@@ -1,12 +1,12 @@
{
"name": "opencrm-backend",
"version": "1.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencrm-backend",
"version": "1.1.0",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
@@ -14,20 +14,15 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -40,10 +35,10 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
},
@@ -54,6 +49,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@@ -69,6 +65,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -84,6 +81,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -99,6 +97,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -114,6 +113,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -129,6 +129,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -144,6 +145,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -159,6 +161,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -174,6 +177,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -189,6 +193,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -204,6 +209,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -219,6 +225,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -234,6 +241,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -249,6 +257,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -264,6 +273,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -279,6 +289,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -294,6 +305,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -309,6 +321,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -324,6 +337,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -339,6 +353,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -354,6 +369,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -369,6 +385,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
@@ -384,6 +401,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@@ -399,6 +417,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -414,6 +433,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -429,6 +449,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -486,8 +507,7 @@
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -724,13 +744,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
@@ -962,7 +975,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
@@ -1046,10 +1058,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -1438,33 +1449,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz",
"integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^17.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand/node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1559,6 +1543,7 @@
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@@ -1677,24 +1662,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz",
"integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
@@ -1785,6 +1752,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -1841,6 +1809,7 @@
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@@ -1913,15 +1882,6 @@
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@@ -2005,31 +1965,19 @@
]
},
"node_modules/imapflow": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
"license": "MIT",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"dependencies": {
"@zone-eu/mailsplit": "5.4.9",
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "8.0.7",
"pino": "10.3.1",
"socks": "2.8.8"
}
},
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
"version": "5.4.9",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1"
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
@@ -2047,27 +1995,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2260,10 +2187,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@@ -2306,19 +2232,18 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/mailparser": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.8",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "8.0.5",
"nodemailer": "7.0.13",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
@@ -2338,27 +2263,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2422,12 +2326,11 @@
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2494,15 +2397,6 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -2542,7 +2436,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -2612,10 +2505,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/pdf-lib": {
"version": "1.17.1",
@@ -2662,10 +2554,9 @@
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
@@ -2687,7 +2578,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
@@ -2695,8 +2585,7 @@
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/png-js": {
"version": "1.0.0",
@@ -2748,8 +2637,7 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
]
},
"node_modules/proxy-addr": {
"version": "2.0.7",
@@ -2772,10 +2660,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -2789,8 +2676,7 @@
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
"node_modules/range-parser": {
"version": "1.2.1",
@@ -2842,10 +2728,9 @@
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -2857,7 +2742,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
@@ -2866,6 +2750,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
@@ -2898,7 +2783,6 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
@@ -3079,19 +2963,17 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
"license": "MIT",
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"dependencies": {
"ip-address": "^10.1.1",
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -3099,20 +2981,10 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socks/node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
@@ -3121,7 +2993,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
@@ -3275,7 +3146,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
@@ -3313,6 +3183,7 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3363,10 +3234,9 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"engines": {
"node": ">=18.17"
}
+4 -11
View File
@@ -1,10 +1,10 @@
{
"name": "opencrm-backend",
"version": "1.1.0",
"version": "1.0.0",
"description": "OpenCRM Backend API",
"main": "dist/index.js",
"prisma": {
"seed": "npx tsx prisma/seed.ts"
"seed": "tsx prisma/seed.ts"
},
"scripts": {
"dev": "tsx watch src/index.ts",
@@ -12,12 +12,10 @@
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"schema:sync": "prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
"db:restore": "tsx prisma/restore-data.ts",
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
"db:restore": "tsx prisma/restore-data.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
@@ -26,20 +24,15 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -52,10 +45,10 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
+25 -52
View File
@@ -1,15 +1,12 @@
/**
* Datenbank-Backup Script
*
* Exportiert ALLE Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
* Exportiert alle Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
*
* Verwendung:
* npm run db:backup
* npx ts-node prisma/backup-data.ts
*
* Erstellt einen Ordner 'prisma/backups/YYYY-MM-DDTHH-mm-ss/' mit JSON-Dateien pro Tabelle.
*
* Die Tabellen sind nach Abhängigkeitsreihenfolge sortiert (Level 0 = keine FKs, dann aufsteigend).
* Damit kann das Restore-Script sie in der gleichen Reihenfolge einspielen, ohne FK-Verletzungen.
* Erstellt einen Ordner 'backups/YYYY-MM-DD_HH-mm-ss/' mit JSON-Dateien pro Tabelle.
*/
import { PrismaClient } from '@prisma/client';
@@ -31,7 +28,7 @@ async function main() {
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
const tables = [
// ============ Level 0: Reine Stammdaten/Kataloge ============
// Level 0: Keine Abhängigkeiten
{ name: 'Permission', query: () => prisma.permission.findMany() },
{ name: 'Role', query: () => prisma.role.findMany() },
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
@@ -40,58 +37,40 @@ async function main() {
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
{ name: 'Provider', query: () => prisma.provider.findMany() },
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'EnergyProvider', query: () => prisma.energyProvider.findMany() },
{ name: 'TelecomProvider', query: () => prisma.telecomProvider.findMany() },
// ============ Level 1: Abhängig von Level 0 ============
// Level 1: Abhängig von Level 0
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
{ name: 'User', query: () => prisma.user.findMany() },
{ name: 'Customer', query: () => prisma.customer.findMany() },
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
// ============ Level 2: Abhängig von Customer ============
// Level 2: Abhängig von Level 1
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
// ============ Level 3: Contracts + abhängige ============
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'Contract', query: () => prisma.contract.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
// ============ Level 4: Vertragstyp-Details + Sub-Tabellen ============
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'ContractHistoryEntry', query: () => prisma.contractHistoryEntry.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'Invoice', query: () => prisma.invoice.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
// ============ Level 5: Sub-Tabellen der Sub-Tabellen ============
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
// ============ Level 6: Logs & Emails (wachsende Tabellen) ============
// Level 3: Abhängig von Level 2
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
{ name: 'ContractNote', query: () => prisma.contractNote.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
// Level 4: Abhängig von Level 3
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
// Vertragstyp-spezifische Details
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'TelecomContractDetails', query: () => prisma.telecomContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
];
let totalRecords = 0;
const stats: { table: string; count: number }[] = [];
const skipped: string[] = [];
for (const table of tables) {
try {
@@ -100,7 +79,7 @@ async function main() {
totalRecords += count;
stats.push({ table: table.name, count });
// JSON-Datei schreiben (Date-Felder als ISO-String)
// JSON-Datei schreiben
const filePath = path.join(backupDir, `${table.name}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
@@ -108,26 +87,20 @@ async function main() {
console.log(`${status} ${table.name}: ${count} Einträge`);
} catch (error: any) {
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 80)}...)`);
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 50)}...)`);
}
}
// Backup-Info speichern
const backupInfo = {
timestamp: new Date().toISOString(),
schemaVersion: 'current',
totalRecords,
tables: stats,
skippedTables: skipped,
};
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
console.log(`\n✅ Backup abgeschlossen!`);
console.log(` 📊 ${totalRecords} Datensätze in ${stats.filter(s => s.count > 0).length} Tabellen`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen übersprungen: ${skipped.join(', ')}`);
}
console.log(` 📁 Gespeichert in: ${backupDir}\n`);
}
@@ -1,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;
@@ -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;
@@ -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`);
+373 -144
View File
@@ -4,40 +4,43 @@
* Stellt Daten aus einem JSON-Backup wieder her.
*
* Verwendung:
* npm run db:restore # Letztes Backup
* npx tsx prisma/restore-data.ts <ordner> # Bestimmtes Backup
* npx ts-node prisma/restore-data.ts [backup-ordner]
*
* WICHTIG: Führe vorher 'npx prisma db push' oder 'npx prisma migrate deploy' aus,
* damit das Schema zur DB passt!
* Beispiele:
* npx ts-node prisma/restore-data.ts # Letztes Backup
* npx ts-node prisma/restore-data.ts 2025-01-31_14-30-00 # Bestimmtes Backup
*
* WICHTIG: Führe vorher 'npx prisma migrate deploy' oder 'npx prisma db push' aus!
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
// Hilfsfunktion: JSON-Datei lesen (leer bei fehlender Datei)
// Hilfsfunktion: JSON-Datei lesen
function readJsonFile<T>(filePath: string): T[] {
if (!fs.existsSync(filePath)) return [];
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch {
if (!fs.existsSync(filePath)) {
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
}
// Hilfsfunktion: ISO-Datum-Strings rekursiv zu Date-Objekten
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
function convertDates(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
// ISO-Datumsformat erkennen
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
return new Date(obj);
}
return obj;
}
if (Array.isArray(obj)) return obj.map(convertDates);
if (Array.isArray(obj)) {
return obj.map(convertDates);
}
if (typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
@@ -48,75 +51,19 @@ function convertDates(obj: any): any {
return obj;
}
/**
* Generischer Restore-Helper: nutzt createMany mit skipDuplicates
* wenn möglich, sonst einzelnes upsert per ID.
*/
async function restoreTable<T extends { id?: any }>(
tableName: string,
data: T[],
model: any,
options: { useCreateMany?: boolean; compositeKey?: string[] } = {},
): Promise<number> {
if (data.length === 0) return 0;
const converted = data.map(convertDates) as T[];
// Bei einfachen Tabellen: createMany mit skipDuplicates
if (options.useCreateMany) {
try {
const result = await model.createMany({
data: converted,
skipDuplicates: true,
});
return result.count;
} catch (err: any) {
// Fallback auf einzeln
}
}
// Upsert per ID (oder Composite-Key)
let count = 0;
for (const item of converted) {
try {
if (options.compositeKey) {
const where: any = {};
const compositeWhere: any = {};
for (const key of options.compositeKey) {
compositeWhere[key] = (item as any)[key];
}
where[options.compositeKey.join('_')] = compositeWhere;
await model.upsert({
where,
update: {},
create: item,
});
} else {
await model.upsert({
where: { id: (item as any).id },
update: item,
create: item,
});
}
count++;
} catch (err: any) {
console.log(` ⚠️ Eintrag in ${tableName} (id=${(item as any).id}): ${err.message?.slice(0, 80)}`);
}
}
return count;
}
async function main() {
// Backup-Ordner bestimmen
const backupsDir = path.join(__dirname, 'backups');
let backupName = process.argv[2];
if (!backupName) {
// Neuestes Backup finden
if (!fs.existsSync(backupsDir)) {
console.error('❌ Kein Backup-Ordner gefunden!');
process.exit(1);
}
const backups = fs.readdirSync(backupsDir)
.filter((f) => fs.statSync(path.join(backupsDir, f)).isDirectory())
.filter(f => fs.statSync(path.join(backupsDir, f)).isDirectory())
.sort()
.reverse();
@@ -129,16 +76,18 @@ async function main() {
}
const backupDir = path.join(backupsDir, backupName);
if (!fs.existsSync(backupDir)) {
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
process.exit(1);
}
// Backup-Info lesen
const infoPath = path.join(backupDir, '_backup-info.json');
if (fs.existsSync(infoPath)) {
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
console.log(`\n📅 Backup vom: ${new Date(info.timestamp).toLocaleString('de-DE')}`);
console.log(`📊 ${info.totalRecords} Datensätze\n`);
console.log(`📊 ${info.totalRecords} Datensätze in ${info.tables.filter((t: any) => t.count > 0).length} Tabellen\n`);
}
console.log(`🔄 Starte Wiederherstellung aus: ${backupDir}\n`);
@@ -147,76 +96,362 @@ async function main() {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
try {
// Tabellen in Abhängigkeitsreihenfolge (gleich wie im Backup)
const order: Array<{
name: string;
model: any;
compositeKey?: string[];
}> = [
// Level 0
{ name: 'Permission', model: prisma.permission },
{ name: 'Role', model: prisma.role },
{ name: 'SalesPlatform', model: prisma.salesPlatform },
{ name: 'ContractCategory', model: prisma.contractCategory },
{ name: 'CancellationPeriod', model: prisma.cancellationPeriod },
{ name: 'ContractDuration', model: prisma.contractDuration },
{ name: 'AppSetting', model: prisma.appSetting },
{ name: 'EmailProviderConfig', model: prisma.emailProviderConfig },
{ name: 'Provider', model: prisma.provider },
{ name: 'PdfTemplate', model: prisma.pdfTemplate },
{ name: 'AuditRetentionPolicy', model: prisma.auditRetentionPolicy },
// Tabellen in Abhängigkeitsreihenfolge wiederherstellen
const restoreOrder = [
// Level 0: Keine Abhängigkeiten
{
name: 'Permission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.permission.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Role',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.role.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SalesPlatform',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.salesPlatform.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractCategory',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractCategory.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CancellationPeriod',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cancellationPeriod.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDuration',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDuration.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AppSetting',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.appSetting.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailProviderConfig',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailProviderConfig.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EnergyProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 1
{ name: 'RolePermission', model: prisma.rolePermission, compositeKey: ['roleId', 'permissionId'] },
{ name: 'User', model: prisma.user },
{ name: 'Customer', model: prisma.customer },
{ name: 'Tariff', model: prisma.tariff },
{
name: 'RolePermission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'User',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.user.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Customer',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customer.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Tariff',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.tariff.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 2: Customer-abhängig
{ name: 'UserRole', model: prisma.userRole, compositeKey: ['userId', 'roleId'] },
{ name: 'Address', model: prisma.address },
{ name: 'BankCard', model: prisma.bankCard },
{ name: 'IdentityDocument', model: prisma.identityDocument },
{ name: 'Meter', model: prisma.meter },
{ name: 'StressfreiEmail', model: prisma.stressfreiEmail },
{ name: 'CustomerRepresentative', model: prisma.customerRepresentative },
{ name: 'CustomerConsent', model: prisma.customerConsent },
{ name: 'DataDeletionRequest', model: prisma.dataDeletionRequest },
// Level 2
{
name: 'UserRole',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'CustomerRepresentative',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerRepresentative.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'StressfreiEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.stressfreiEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Contract',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contract.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Meter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 3: Contracts
{ name: 'Contract', model: prisma.contract },
{ name: 'RepresentativeAuthorization', model: prisma.representativeAuthorization },
// Level 3
{
name: 'CachedEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cachedEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractTask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'MeterReading',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meterReading.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractNote',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractNote.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 4: Vertragstyp-Details
{ name: 'EnergyContractDetails', model: prisma.energyContractDetails },
{ name: 'InternetContractDetails', model: prisma.internetContractDetails },
{ name: 'MobileContractDetails', model: prisma.mobileContractDetails },
{ name: 'TvContractDetails', model: prisma.tvContractDetails },
{ name: 'CarInsuranceDetails', model: prisma.carInsuranceDetails },
{ name: 'ContractMeter', model: prisma.contractMeter },
{ name: 'ContractDocument', model: prisma.contractDocument },
{ name: 'ContractHistoryEntry', model: prisma.contractHistoryEntry },
{ name: 'ContractTask', model: prisma.contractTask },
{ name: 'Invoice', model: prisma.invoice },
{ name: 'MeterReading', model: prisma.meterReading },
// Level 4
{
name: 'ContractTaskSubtask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTaskSubtask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 5: Sub-Tabellen
{ name: 'ContractTaskSubtask', model: prisma.contractTaskSubtask },
{ name: 'PhoneNumber', model: prisma.phoneNumber },
{ name: 'SimCard', model: prisma.simCard },
// Level 6: Logs & Emails
{ name: 'CachedEmail', model: prisma.cachedEmail },
{ name: 'EmailLog', model: prisma.emailLog },
{ name: 'AuditLog', model: prisma.auditLog },
// Vertragsdetails
{
name: 'EnergyContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CarInsuranceDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.carInsuranceDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
];
let totalRestored = 0;
const skipped: string[] = [];
for (const table of order) {
for (const table of restoreOrder) {
const filePath = path.join(backupDir, `${table.name}.json`);
const data = readJsonFile<any>(filePath);
const data = readJsonFile(filePath);
if (data.length === 0) {
console.log(`${table.name}: Keine Daten`);
@@ -224,23 +459,17 @@ async function main() {
}
try {
const count = await restoreTable(table.name, data, table.model, {
compositeKey: table.compositeKey,
});
totalRestored += count;
console.log(`${table.name}: ${count}/${data.length} Einträge wiederhergestellt`);
await table.restore(data);
totalRestored += data.length;
console.log(`${table.name}: ${data.length} Einträge wiederhergestellt`);
} catch (error: any) {
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 100)}`);
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 80)}`);
}
}
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen mit Fehlern: ${skipped.join(', ')}`);
}
console.log('');
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt\n`);
} finally {
// Foreign Key Checks wieder aktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
-71
View File
@@ -78,10 +78,6 @@ model User {
isActive Boolean @default(true)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Messaging-Kanäle (für Datenschutz-Link-Versand)
whatsappNumber String?
telegramUsername String?
@@ -167,22 +163,9 @@ model Customer {
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
portalLastLogin DateTime? // Letzte Anmeldung
// Portal Passwort-Reset
portalPasswordResetToken String? @unique
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
// Anrede-Verhältnis: true = Du (informell), false = Sie (formell, Default)
useInformalAddress Boolean @default(false)
// Automatischer Geburtstagsgruß-Versand
autoBirthdayGreeting Boolean @default(false)
autoBirthdayChannel String? // "email", "whatsapp", "telegram", "signal"
user User?
addresses Address[]
bankCards BankCard[]
@@ -368,10 +351,6 @@ model EmailProviderConfig {
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
customerEmailLabel String?
isActive Boolean @default(true)
isDefault Boolean @default(false) // Standard-Provider
createdAt DateTime @default(now())
@@ -1113,53 +1092,3 @@ model AuditRetentionPolicy {
@@unique([resourceType, sensitivity])
}
// ==================== SECURITY MONITORING ====================
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
// effizient querybar). Threshold-Detection läuft per Cron.
enum SecurityEventType {
LOGIN_FAILED // falsches Passwort / unbekannter User
LOGIN_SUCCESS // erfolgreicher Login (informativ)
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
SUSPICIOUS // generischer Catch-All
}
enum SecuritySeverity {
INFO // Login-Success, Logout
LOW // Einzelner failed Login, einzelner 403
MEDIUM // Rate-Limit-Hit, mehrere 403er
HIGH // SSRF-Block, JWT-Manipulation
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
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])
}
-368
View File
@@ -1,368 +0,0 @@
/**
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
*
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
* - Merged mehrere Dateien pro Kategorie automatisch
* - Nutzt Prisma upsert → idempotent, kann mehrfach aufgerufen werden
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
*
* Aufruf:
* npm run seed:defaults
*/
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ROOT kann via FACTORY_DEFAULTS_DIR überschrieben werden (Container-Bootstrap
// mit eingebauten Defaults aus dem Image).
const ROOT = process.env.FACTORY_DEFAULTS_DIR
? path.resolve(process.env.FACTORY_DEFAULTS_DIR)
: path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
interface ProviderDef {
name: string;
portalUrl?: string | null;
usernameFieldName?: string | null;
passwordFieldName?: string | null;
isActive?: boolean;
tariffs?: { name: string; isActive?: boolean }[];
}
interface CancellationPeriodDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractDurationDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractCategoryDef {
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}
interface PdfTemplateDef {
name: string;
description?: string | null;
providerName?: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix?: string | null;
maxPhoneFields?: number | null;
isActive?: boolean;
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
}
interface AppSettingDef {
key: string;
value: string;
}
// Whitelist muss synchron zu factoryDefaults.service.ts sein.
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
'privacyPolicyHtml',
'authorizationTemplateHtml',
'imprintHtml',
'websitePrivacyPolicyHtml',
]);
/**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/
function readJsonArrays<T>(dir: string): T[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const result: T[] = [];
for (const f of files) {
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
result.push(...data);
}
} catch (e) {
console.warn(`⚠ Konnte ${f} nicht parsen überspringe.`);
}
}
return result;
}
/**
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
*/
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return Array.from(map.values());
}
async function seedProviders() {
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
if (items.length === 0) {
console.log(' providers/ keine Einträge gefunden');
return;
}
let providerCount = 0;
let tariffCount = 0;
for (const p of items) {
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
create: {
name: p.name,
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
});
providerCount++;
if (p.tariffs && p.tariffs.length > 0) {
for (const t of p.tariffs) {
await prisma.tariff.upsert({
where: { providerId_name: { providerId: provider.id, name: t.name } },
update: { isActive: t.isActive ?? true },
create: {
providerId: provider.id,
name: t.name,
isActive: t.isActive ?? true,
},
});
tariffCount++;
}
}
}
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
}
async function seedCancellationPeriods() {
const items = dedupe(
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' cancellation-periods keine Einträge');
return;
}
for (const c of relevant) {
await prisma.cancellationPeriod.upsert({
where: { code: c.code },
update: { description: c.description, isActive: c.isActive ?? true },
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
});
}
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
}
async function seedContractDurations() {
const items = dedupe(
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' contract-durations keine Einträge');
return;
}
for (const d of relevant) {
await prisma.contractDuration.upsert({
where: { code: d.code },
update: { description: d.description, isActive: d.isActive ?? true },
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
});
}
console.log(` ✓ Laufzeiten: ${relevant.length}`);
}
async function seedContractCategories() {
const items = dedupe(
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
(i) => i.code,
);
if (items.length === 0) {
console.log(' contract-categories keine Einträge');
return;
}
for (const c of items) {
await prisma.contractCategory.upsert({
where: { code: c.code },
update: {
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
create: {
code: c.code,
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
});
}
console.log(` ✓ Vertragskategorien: ${items.length}`);
}
async function seedPdfTemplates() {
const items = dedupe(
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
(t) => t.name,
);
if (items.length === 0) {
console.log(' pdf-templates keine Einträge');
return;
}
// Upload-Verzeichnis sicherstellen
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
}
let count = 0;
let skipped = 0;
for (const t of items) {
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
if (!fs.existsSync(srcPdf)) {
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} Template "${t.name}" übersprungen`);
skipped++;
continue;
}
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.copyFileSync(srcPdf, destPdf);
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
if (existing?.templatePath) {
const oldRel = existing.templatePath.startsWith('/uploads/')
? existing.templatePath.substring('/uploads/'.length)
: existing.templatePath;
const oldAbs = path.join(UPLOADS_ROOT, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
await prisma.pdfTemplate.upsert({
where: { name: t.name },
update: {
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
create: {
name: t.name,
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
});
count++;
}
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function seedAppSettings() {
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
if (items.length === 0) {
console.log(' app-settings keine Einträge');
return;
}
let count = 0;
let skipped = 0;
for (const s of items) {
if (!s.key || typeof s.value !== 'string') continue;
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist übersprungen`);
skipped++;
continue;
}
await prisma.appSetting.upsert({
where: { key: s.key },
update: { value: s.value },
create: { key: s.key, value: s.value },
});
count++;
}
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
if (!fs.existsSync(ROOT)) {
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
process.exit(1);
}
await seedProviders();
await seedCancellationPeriods();
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
await seedAppSettings();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
main()
.catch((e) => {
console.error('\n❌ Fehler beim Einspielen:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+4 -182
View File
@@ -1,14 +1,12 @@
import { Request, Response } from 'express';
import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
// Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -18,25 +16,8 @@ export async function login(req: Request, res: Response): Promise<void> {
}
const result = await authService.login(email, password);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Mitarbeiter-Login: ${email}`,
ipAddress: ctx.ipAddress,
userId: result.user.id,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
@@ -46,9 +27,9 @@ export async function login(req: Request, res: Response): Promise<void> {
// Kundenportal-Login
export async function customerLogin(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -58,25 +39,8 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
}
const result = await authService.customerLogin(email, password);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Portal-Login: ${email}`,
ipAddress: ctx.ipAddress,
customerId: result.user.customerId,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
@@ -135,148 +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;
}
if (password.length < 6) {
res.status(400).json({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
} 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() },
});
}
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'LOGOUT',
severity: 'INFO',
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
customerId: ctx.customerId,
userEmail: user.email,
endpoint: ctx.endpoint,
});
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Abmelden',
} as ApiResponse);
}
}
export async function register(req: Request, res: Response): Promise<void> {
try {
const { email, password, firstName, lastName, roleIds } = req.body;
+6 -15
View File
@@ -1,14 +1,5 @@
import { Request, Response } from 'express';
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';
/**
@@ -54,8 +45,8 @@ export async function restoreBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.restoreBackup(name);
@@ -88,8 +79,8 @@ export async function deleteBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.deleteBackup(name);
@@ -116,8 +107,8 @@ export async function downloadBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.createBackupZip(name);
@@ -1,9 +1,6 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Admin/Mitarbeiter: Kommende und vergangene Geburtstage
@@ -57,125 +54,3 @@ export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
/**
* Admin: Geburtstagsgruß-Marker für einen Kunden zurücksetzen (Debug / Re-Trigger).
*/
export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
await birthdayService.resetBirthdayGreeting(customerId);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'UPDATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß-Marker zurückgesetzt`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({ success: true });
} catch (error) {
console.error('Fehler beim Zurücksetzen:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
});
}
}
/**
* Admin: Geburtstagsgruß manuell senden (Email oder Link für WhatsApp/Telegram/Signal).
*/
export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal'
if (!['email', 'whatsapp', 'telegram', 'signal'].includes(channel)) {
return res.status(400).json({ success: false, error: 'Ungültiger Kanal' });
}
const data = await birthdayService.getBirthdayGreetingData(customerId);
if (!data) {
return res.status(400).json({
success: false,
error: 'Kunde hat kein Geburtsdatum hinterlegt',
});
}
const { subject, plain, html } = birthdayService.buildBirthdayGreetingText(data, data.age);
if (channel === 'email') {
if (!data.email) {
return res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
});
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: data.email,
subject,
html,
}, {
context: 'birthday-greeting',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'CREATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({
success: true,
data: { channel, messageText: plain },
});
} catch (error) {
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
+58 -240
View File
@@ -11,25 +11,17 @@ import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import {
canAccessCustomer,
canAccessContract,
canAccessCachedEmail,
} from '../utils/accessControl.js';
// ==================== E-MAIL LIST ====================
// E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
@@ -55,10 +47,9 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
}
// E-Mails für einen Vertrag abrufen
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
@@ -84,11 +75,9 @@ export async function getEmailsForContract(req: AuthRequest, res: Response): Pro
// ==================== SINGLE EMAIL ====================
// Einzelne E-Mail abrufen (mit Body)
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
export async function getEmail(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const email = await cachedEmailService.getCachedEmailById(id);
if (!email) {
@@ -257,30 +246,12 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
}
}
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
function hasCRLF(value: unknown): boolean {
if (typeof value === 'string') return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some(hasCRLF);
return false;
}
// E-Mail senden
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
res.status(400).json({
success: false,
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -425,10 +396,9 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
// ==================== ATTACHMENTS ====================
// Anhang-Liste einer E-Mail abrufen
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
export async function getAttachments(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -459,14 +429,11 @@ export async function getAttachments(req: AuthRequest, res: Response): Promise<v
}
// Einzelnen Anhang herunterladen
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
const filename = decodeURIComponent(req.params.filename);
// Portal-Isolation: nur eigene/vertretene Emails
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
@@ -533,48 +500,17 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
return;
}
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
// Datei senden - inline (öffnen) oder attachment (download)
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
res.setHeader('Content-Type', attachment.contentType);
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
res.setHeader('Content-Length', attachment.size);
res.send(attachment.content);
} catch (error) {
console.error('downloadAttachment error:', error);
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
const lower = rawMsg.toLowerCase();
let friendly = rawMsg;
if (lower.includes('socket disconnected') && lower.includes('tls')) {
friendly =
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
} else if (lower.includes('econnrefused')) {
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
} else if (lower.includes('etimedout')) {
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
} else if (lower.includes('authentication') || lower.includes('auth')) {
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
}
res.status(500).json({
success: false,
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
error: 'Fehler beim Herunterladen des Anhangs',
} as ApiResponse);
}
}
@@ -1643,7 +1579,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
// Vertrag laden
// Vertrag laden und prüfen ob es ein Energievertrag ist
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1657,6 +1593,22 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Empfänger-Adressen parsen (JSON Array)
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
@@ -1693,20 +1645,13 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// PDF speichern
fs.writeFileSync(filePath, pdfBuffer);
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
res.json({
success: true,
@@ -1770,7 +1715,7 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
// Vertrag laden
// Vertrag laden und prüfen ob es ein Energievertrag ist
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1784,6 +1729,22 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
@@ -1855,20 +1816,13 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
// Datei speichern
fs.writeFileSync(filePath, attachment.content);
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
res.json({
success: true,
@@ -1883,139 +1837,3 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
} as ApiResponse);
}
}
/**
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
*/
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body;
if (!documentType || typeof documentType !== 'string') {
res.status(400).json({
success: false,
error: 'documentType ist erforderlich',
} as ApiResponse);
return;
}
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
return;
}
if (!email.contractId) {
res.status(400).json({
success: false,
error: 'E-Mail ist keinem Vertrag zugeordnet',
} as ApiResponse);
return;
}
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
select: { id: true, contractNumber: true, customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
return;
}
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
success: false,
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
} as ApiResponse);
return;
}
// StressfreiEmail für IMAP-Zugangsdaten
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
res.status(400).json({
success: false,
error: 'Keine Mailbox-Zugangsdaten verfügbar',
} as ApiResponse);
return;
}
const settings = await getImapSmtpSettings();
if (!settings) {
res.status(400).json({
success: false,
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
} as ApiResponse);
return;
}
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
if (!attachment) {
res.status(404).json({
success: false,
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
} as ApiResponse);
return;
}
// Uploads-Verzeichnis
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const ext = path.extname(filename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
}
}
+9 -100
View File
@@ -6,8 +6,6 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -256,64 +254,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
}
}
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
*/
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
export async function getContractPassword(req: Request, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
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);
const password = await contractService.getContractPassword(parseInt(req.params.id));
if (password === null) {
res.status(404).json({
success: false,
@@ -330,21 +273,9 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
}
}
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
try {
const simCardId = parseInt(req.params.simCardId);
// SimCard → MobileDetails → Contract
const sim = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { mobileDetails: { select: { contractId: true } } },
});
if (!sim?.mobileDetails) {
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
const credentials = await contractService.getSimCardCredentials(simCardId);
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -354,12 +285,9 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
}
}
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const credentials = await contractService.getInternetCredentials(contractId);
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -369,21 +297,9 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
}
}
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
try {
const phoneNumberId = parseInt(req.params.phoneNumberId);
// PhoneNumber → InternetDetails → Contract
const phone = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { internetDetails: { select: { contractId: true } } },
});
if (!phone?.internetDetails) {
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
const credentials = await contractService.getSipCredentials(phoneNumberId);
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -499,8 +415,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const documents = await prisma.contractDocument.findMany({
where: { contractId },
orderBy: { createdAt: 'desc' },
@@ -514,8 +428,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
const { documentType, notes } = req.body;
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
@@ -548,9 +461,6 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
customerId: contract?.customerId,
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
@@ -564,7 +474,6 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
try {
const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) {
+34 -87
View File
@@ -4,23 +4,9 @@ import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
sanitizeCustomers,
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
} from '../utils/sanitize.js';
import {
canAccessMeter,
canAccessAddress,
canAccessBankCard,
canAccessIdentityDocument,
canAccessCustomer,
} from '../utils/accessControl.js';
// Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
export async function getCustomers(req: Request, res: Response): Promise<void> {
try {
const { search, type, page, limit } = req.query;
const result = await customerService.getAllCustomers({
@@ -29,25 +15,7 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
let customers = result.customers as any[];
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
// an einen einzelnen Portal-Account preisgeben.
if (req.user?.isCustomerPortal) {
const allowedIds = new Set<number>();
if (req.user.customerId) allowedIds.add(req.user.customerId);
const represented = (req.user as any).representedCustomerIds || [];
for (const id of represented) allowedIds.add(id);
customers = customers.filter((c) => allowedIds.has(c.id));
}
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomers(customers)
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -56,21 +24,14 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
}
}
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
export async function getCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.id);
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await customerService.getCustomerById(customerId);
const customer = await customerService.getCustomerById(parseInt(req.params.id));
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
res.json({ success: true, data: customer } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
}
@@ -78,8 +39,7 @@ export async function getCustomer(req: AuthRequest, res: Response): Promise<void
export async function createCustomer(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
const data = { ...req.body };
// Convert birthDate string to Date if present
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -103,8 +63,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
export async function updateCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data: any = pickCustomerUpdate(req.body);
const data = { ...req.body };
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ where: { id: customerId } });
@@ -129,9 +88,6 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
useInformalAddress: 'Anrede per',
autoBirthdayGreeting: 'Autom. Geburtstagsgruß',
autoBirthdayChannel: 'Kanal für Geburtstagsgruß',
};
for (const [key, value] of Object.entries(data)) {
// Technische/interne Felder überspringen
@@ -201,21 +157,18 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
}
// Addresses
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
export async function getAddresses(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const addresses = await customerService.getCustomerAddresses(customerId);
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
res.json({ success: true, data: addresses } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
}
}
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
export async function createAddress(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const address = await customerService.createAddress(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Address',
@@ -317,22 +270,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
}
// Bank Cards
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
export async function getBankCards(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
const cards = await customerService.getCustomerBankCards(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: cards } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
}
}
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
export async function createBankCard(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const card = await customerService.createBankCard(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'BankCard',
@@ -429,22 +382,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
}
// Identity Documents
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
export async function getDocuments(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
const docs = await customerService.getCustomerDocuments(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: docs } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
}
}
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
export async function createDocument(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const doc = await customerService.createDocument(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'IdentityDocument',
@@ -547,22 +500,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
}
// Meters
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
export async function getMeters(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const meters = await customerService.getCustomerMeters(customerId, showInactive);
const meters = await customerService.getCustomerMeters(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: meters } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
}
}
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
export async function createMeter(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Meter',
@@ -655,11 +608,9 @@ export async function deleteMeter(req: Request, res: Response): Promise<void> {
}
// Meter Readings
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readings = await customerService.getMeterReadings(meterId);
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
res.json({ success: true, data: readings } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
@@ -866,11 +817,9 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
// ==================== PORTAL SETTINGS ====================
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const settings = await customerService.getPortalSettings(customerId);
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
if (!settings) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
@@ -998,12 +947,10 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
// ==================== REPRESENTATIVE MANAGEMENT ====================
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Wer kann diesen Kunden vertreten (representedBy)?
const representedBy = await customerService.getRepresentedByList(customerId);
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
res.json({ success: true, data: representedBy } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -4,14 +4,6 @@ import { Request, Response } from 'express';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
import { decrypt } from '../utils/encryption.js';
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ==================== CONFIG CRUD ====================
@@ -120,33 +112,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
domain: req.body.domain,
} : undefined;
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host');
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err.message,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { apiUrl: testData.apiUrl },
});
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
// URL-Parse-Fehler ignorieren Backend reagiert sowieso mit Fehler
}
}
const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
@@ -157,188 +122,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
}
}
/**
* Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers.
* - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal)
* - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert)
*/
export async function testMailAccess(req: Request, res: Response): Promise<void> {
try {
const id = req.body?.id ? parseInt(req.body.id) : undefined;
const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined;
const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined;
let emailAddress: string | undefined;
let password: string | undefined;
let smtpServer: string;
let smtpPort: number;
let imapServer: string;
let imapPort: number;
let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let allowSelfSignedCerts: boolean;
if (id) {
// Gespeicherten Provider laden
const config = await prisma.emailProviderConfig.findUnique({ where: { id } });
if (!config) {
res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse);
return;
}
emailAddress = bodyEmail || config.systemEmailAddress || undefined;
if (bodyPassword) {
password = bodyPassword;
} else if (config.systemEmailPasswordEncrypted) {
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
password = undefined;
}
}
// IMAP/SMTP-Settings vom Provider ableiten
const settings = await emailProviderService.getImapSmtpSettings();
if (!settings) {
res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse);
return;
}
smtpServer = settings.smtpServer;
smtpPort = settings.smtpPort;
imapServer = settings.imapServer;
imapPort = settings.imapPort;
smtpEncryption = settings.smtpEncryption;
imapEncryption = settings.imapEncryption;
allowSelfSignedCerts = settings.allowSelfSignedCerts;
} else if (req.body?.apiUrl) {
// Formulardaten ohne gespeicherten Provider
emailAddress = bodyEmail;
password = bodyPassword;
try {
const url = new URL(req.body.apiUrl);
smtpServer = url.hostname;
imapServer = url.hostname;
} catch {
smtpServer = `mail.${req.body.domain || ''}`;
imapServer = smtpServer;
}
imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts = !!req.body.allowSelfSignedCerts;
imapPort = imapEncryption === 'SSL' ? 993 : 143;
smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25;
} else {
res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse);
return;
}
if (!emailAddress || !password) {
res.status(400).json({
success: false,
error: 'System-E-Mail-Adresse und Passwort sind erforderlich',
} as ApiResponse);
return;
}
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
]);
} catch (err) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { smtpServer, imapServer },
});
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
} as ApiResponse);
return;
}
// IMAP testen
const imapCredentials: ImapCredentials = {
host: imapResolved.ip,
port: imapPort,
user: emailAddress,
password,
encryption: imapEncryption,
allowSelfSignedCerts,
servername: imapResolved.servername,
};
// SMTP testen
const smtpCredentials: SmtpCredentials = {
host: smtpResolved.ip,
port: smtpPort,
user: emailAddress,
password,
encryption: smtpEncryption,
allowSelfSignedCerts,
servername: smtpResolved.servername,
};
let imapResult: { success: boolean; error?: string } = { success: false };
let smtpResult: { success: boolean; error?: string } = { success: false };
try {
await testImapConnection(imapCredentials);
imapResult = { success: true };
} catch (e) {
imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
try {
await testSmtpConnection(smtpCredentials);
smtpResult = { success: true };
} catch (e) {
smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
res.json({
success: imapResult.success && smtpResult.success,
data: {
imap: {
...imapResult,
server: imapServer,
port: imapPort,
encryption: imapEncryption,
},
smtp: {
...smtpResult,
server: smtpServer,
port: smtpPort,
encryption: smtpEncryption,
},
user: emailAddress,
},
} as ApiResponse);
} catch (error) {
console.error('testMailAccess error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
}
}
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
try {
const { localPart } = req.params;
@@ -398,20 +181,3 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
} as ApiResponse);
}
}
/**
* Öffentliche Provider-Einstellungen für die Frontend-UI:
* Domain + Label für Kunden-E-Mail-Adressen.
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
*/
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
try {
const settings = await emailProviderService.getProviderPublicSettings();
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Einstellungen',
} as ApiResponse);
}
}
@@ -1,101 +0,0 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Factory-Defaults als ZIP exportieren (Download).
*/
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
try {
const buffer = await factoryDefaultsService.exportFactoryDefaults();
const dateStr = new Date().toISOString().split('T')[0];
const filename = `factory-defaults-${dateStr}.zip`;
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'FactoryDefaults',
resourceId: dateStr,
resourceLabel: 'Factory-Defaults exportiert',
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
}
}
/**
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
*/
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
try {
const data = await factoryDefaultsService.collectFactoryDefaults();
res.json({
success: true,
data: {
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
appSettings: data.appSettings.length,
},
},
});
} catch (error) {
console.error('Fehler beim Preview:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
*/
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
try {
const file = (req as any).file as Express.Multer.File | undefined;
if (!file || !file.buffer) {
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
}
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
// den Vorgang explizit als Import.
action: 'UPDATE',
resourceType: 'FactoryDefaults',
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Factory-Defaults-Import:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Import',
});
}
}
@@ -1,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);
}
+10 -33
View File
@@ -4,7 +4,6 @@ import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
@@ -191,12 +190,7 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
}
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
const uploadsDir = path.resolve(process.cwd(), 'uploads');
const filepath = path.resolve(uploadsDir, request.proofDocument);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
}
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
@@ -230,7 +224,6 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
export async function getCustomerConsents(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen
@@ -253,7 +246,6 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
export async function checkConsentStatus(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result });
} catch (error) {
@@ -802,7 +794,6 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
@@ -970,27 +961,12 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body;
// Validierungen:
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
if (representativeId === user.customerId) {
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
}
// 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 } } },
// Vertreter-Name laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { firstName: true, lastName: true },
});
if (!relation) {
return res.status(403).json({
success: false,
error: 'Kein Vertreter-Verhältnis Vollmacht nicht erlaubt',
});
}
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
let auth;
if (grant) {
@@ -1006,9 +982,10 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error);
// Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
// sollten nicht an Endkunden geleakt werden.
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
});
}
}
+8 -16
View File
@@ -1,16 +1,14 @@
import { Request, Response } from 'express';
import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
import { ApiResponse } from '../types/index.js';
/**
* 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 {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoices = await invoiceService.getInvoices(ecdId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
@@ -25,11 +23,10 @@ export async function getInvoices(req: AuthRequest, res: Response): Promise<void
/**
* Einzelne Rechnung abrufen
*/
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function getInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
if (!invoice) {
@@ -53,10 +50,9 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* 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 {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
if (!invoiceDate || !invoiceType) {
@@ -93,11 +89,10 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Rechnung aktualisieren
*/
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function updateInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
@@ -126,11 +121,10 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
/**
* Rechnung löschen
*/
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
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) ====================
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const invoices = await invoiceService.getInvoicesByContract(contractId);
res.json({ success: true, data: invoices } as ApiResponse);
} 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 {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
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 * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getTemplates(req: AuthRequest, res: Response) {
try {
@@ -150,7 +149,6 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs });
} catch (error) {
@@ -162,7 +160,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Extras aus Body (POST) oder Query-Parametern (GET)
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
@@ -1,8 +1,7 @@
import { Request, Response } from 'express';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
import { ApiResponse } from '../types/index.js';
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
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 {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.getEmailById(emailId);
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
if (!email) {
res.status(404).json({
success: false,
@@ -31,13 +27,7 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
} as ApiResponse);
return;
}
// 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);
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
+5 -20
View File
@@ -3,7 +3,6 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -50,8 +49,7 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any);
const user = await userService.createUser(req.body);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -69,31 +67,18 @@ export async function createUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
const data = req.body;
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
const beforeUser = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } },
});
const before = beforeUser
? {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
}
: null;
// Vorherigen Stand laden für Audit
const before = await prisma.user.findUnique({ where: { id: userId } });
const user = await userService.updateUser(userId, data as any);
const user = await userService.updateUser(userId, data);
if (user) {
// Audit: Geänderte Felder ermitteln und loggen
if (before) {
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
+10 -214
View File
@@ -1,33 +1,7 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
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 customerRoutes from './routes/customer.routes.js';
@@ -58,182 +32,24 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.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 { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
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);
}
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// ==================== 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'"],
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
// 'none' würde sogar same-origin blocken und damit die UI brechen.
// Externe Sites bleiben weiterhin gesperrt.
'frame-ancestors': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
// useDefaults bringt 'upgrade-insecure-requests' selbst mit explizit
// auf null setzen entfernt es aus dem Header (helmet-API).
'upgrade-insecure-requests': httpsEnabled ? [] : null,
},
},
// HSTS nur wenn echt TLS vorhanden sonst sperrt sich der Browser
// dauerhaft aus, wenn die App direkt via http://ip:port erreichbar ist.
strictTransportSecurity: httpsEnabled
? { maxAge: 31536000, includeSubDomains: true }
: false,
crossOriginResourcePolicy: { policy: 'same-site' },
}),
);
// 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' }));
// Middleware
app.use(cors());
app.use(express.json());
// Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware);
app.use(auditMiddleware);
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
// `/api/uploads/*` express.static).
// 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);
});
// Statische Dateien für Uploads
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
@@ -267,8 +83,6 @@ app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check
app.get('/api/health', (req, res) => {
@@ -293,29 +107,11 @@ if (process.env.NODE_ENV === 'production') {
}
// Error handling
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `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) => {
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
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 });
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
});
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
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();
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
});
+9 -41
View File
@@ -2,7 +2,6 @@ import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js';
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
export async function authenticate(
req: AuthRequest,
@@ -27,27 +26,27 @@ export async function authenticate(
}
try {
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
}) as JwtPayload;
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || 'fallback-secret'
) as JwtPayload;
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
if (decoded.userId && decoded.iat) {
// Mitarbeiter-Login
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { tokenInvalidatedAt: true, isActive: true },
});
// Benutzer nicht gefunden oder deaktiviert
if (!user || !user.isActive) {
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
return;
}
// Token wurde vor der Invalidierung ausgestellt
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()) {
res.status(401).json({
success: false,
@@ -56,42 +55,11 @@ export async function authenticate(
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;
next();
} catch (err) {
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
endpoint: `${req.method} ${req.path}`,
});
} catch {
res.status(401).json({ success: false, error: 'Ungültiger Token' });
}
}
-65
View File
@@ -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 -8
View File
@@ -1,18 +1,12 @@
import { Router } from 'express';
import * as authController from '../controllers/auth.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
const router = Router();
router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/login', authController.login);
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
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);
export default router;
-4
View File
@@ -7,10 +7,6 @@ const router = Router();
// Admin: Kommende und vergangene Geburtstage
router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays);
// Admin: Gruß-Marker zurücksetzen + Gruß senden
router.post('/:customerId/reset', authenticate, requirePermission('customers:update'), birthdayController.resetBirthdayGreeting);
router.post('/:customerId/send', authenticate, requirePermission('customers:update'), birthdayController.sendBirthdayGreeting);
// Portal: eigener Geburtstag-Check
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
-9
View File
@@ -203,15 +203,6 @@ router.post(
cachedEmailController.saveAttachmentAsInvoice
);
// Anhang als Vertragsdokument speichern
// POST /api/emails/:id/attachments/:filename/save-as-contract-document { documentType, notes? }
router.post(
'/emails/:id/attachments/:filename/save-as-contract-document',
authenticate,
requirePermission('contracts:update'),
cachedEmailController.saveAttachmentAsContractDocument
);
// ==================== VERTRAGSZUORDNUNG ====================
// E-Mail Vertrag zuordnen
-3
View File
@@ -42,9 +42,6 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
// Follow-up contract
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)
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
@@ -15,9 +15,7 @@ router.delete('/configs/:id', authenticate, requirePermission('settings:update')
// Email Operations
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
@@ -1,48 +0,0 @@
import { Router } from 'express';
import multer from 'multer';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
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?)
router.get(
'/preview',
authenticate,
requirePermission('settings:read'),
factoryDefaultsController.previewFactoryDefaults,
);
// Export als ZIP-Download
router.get(
'/export',
authenticate,
requirePermission('settings:update'),
factoryDefaultsController.exportFactoryDefaults,
);
// Import aus ZIP (multipart, Feld 'zip')
router.post(
'/import',
authenticate,
requirePermission('settings:update'),
upload.single('zip'),
factoryDefaultsController.importFactoryDefaults,
);
export default router;
-16
View File
@@ -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;
+4 -4
View File
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
// Provider routes
router.get('/', authenticate, providerController.getProviders);
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.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// 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);
export default router;
+1 -1
View File
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// 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.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
+1 -43
View File
@@ -6,7 +6,6 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router();
@@ -547,7 +546,6 @@ async function handleContractDocumentUpload(
}
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// 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
await prisma.contract.update({
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({
success: true,
data: {
@@ -633,7 +592,6 @@ async function handleContractDocumentDelete(
) {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
+4 -225
View File
@@ -1,49 +1,8 @@
import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
// 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
export async function login(email: string, password: string) {
@@ -67,9 +26,6 @@ export async function login(email: string, password: string) {
});
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');
}
@@ -78,10 +34,6 @@ export async function login(email: string, password: string) {
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
const permissions = new Set<string>();
for (const userRole of user.roles) {
@@ -100,7 +52,7 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
@@ -148,8 +100,6 @@ export async function customerLogin(email: string, password: string) {
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
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');
}
@@ -160,9 +110,6 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
@@ -188,7 +135,7 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
@@ -218,7 +165,7 @@ export async function customerLogin(email: string, password: string) {
export async function setCustomerPortalPassword(customerId: number, password: string) {
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);
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
@@ -261,7 +208,7 @@ export async function createUser(data: {
roleIds: 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({
data: {
@@ -392,171 +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';
}
/**
* 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,
portalPasswordEncrypted: encrypt(newPassword),
portalPasswordResetToken: null,
portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(),
},
});
return;
}
throw new Error('Ungültiger oder bereits verwendeter Link.');
}
+3 -179
View File
@@ -239,17 +239,6 @@ export async function createBackup(): Promise<BackupResult> {
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
// Neue Tabellen
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
];
let totalRecords = 0;
@@ -308,11 +297,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
console.log('[Restore] Lösche alle bestehenden Daten...');
// Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({});
await prisma.securityEvent.deleteMany({});
// Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({});
await prisma.tvContractDetails.deleteMany({});
@@ -325,21 +309,12 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.meterReading.deleteMany({});
await prisma.contractHistoryEntry.deleteMany({});
// Neue Contract-bezogene Tabellen
await prisma.contractDocument.deleteMany({});
await prisma.contractMeter.deleteMany({});
// E-Mail & Verträge
await prisma.cachedEmail.deleteMany({});
await prisma.contractTaskSubtask.deleteMany({});
await prisma.contractTask.deleteMany({});
await prisma.contract.deleteMany({});
// DSGVO + Vollmachten (abhängig von Customer)
await prisma.representativeAuthorization.deleteMany({});
await prisma.customerConsent.deleteMany({});
await prisma.dataDeletionRequest.deleteMany({});
// Kunden-bezogene Daten
await prisma.stressfreiEmail.deleteMany({});
await prisma.meter.deleteMany({});
@@ -353,8 +328,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.user.deleteMany({});
await prisma.customer.deleteMany({});
// Stammdaten & Kataloge
await prisma.pdfTemplate.deleteMany({});
// Stammdaten
await prisma.tariff.deleteMany({});
await prisma.provider.deleteMany({});
await prisma.rolePermission.deleteMany({});
@@ -366,7 +340,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.contractCategory.deleteMany({});
await prisma.emailProviderConfig.deleteMany({});
await prisma.appSetting.deleteMany({});
await prisma.auditRetentionPolicy.deleteMany({});
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
@@ -780,127 +753,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
}
},
},
// Neue Tabellen
{
name: 'PdfTemplate',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.pdfTemplate.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractMeter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractMeter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'RepresentativeAuthorization',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.representativeAuthorization.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CustomerConsent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerConsent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'DataDeletionRequest',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.dataDeletionRequest.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditRetentionPolicy',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditRetentionPolicy.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
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;
@@ -1021,36 +873,8 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
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());
}
}
// ZIP extrahieren
zip.extractAllTo(finalBackupDir, true);
return { success: true, backupName: finalBackupName };
} catch (error: any) {
+3 -137
View File
@@ -140,9 +140,6 @@ export interface MyBirthdayCheck {
isToday: boolean;
daysAgo: number; // 0 = heute, >0 = x Tage her
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
age: number;
}
@@ -156,9 +153,6 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
birthDate: true,
lastBirthdayGreetingYear: true,
},
@@ -166,20 +160,13 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
if (!customer?.birthDate) return null;
const baseInfo = {
firstName: customer.firstName,
lastName: customer.lastName,
salutation: customer.salutation,
useInformalAddress: customer.useInformalAddress,
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
// Schon dieses Jahr angezeigt?
if (customer.lastBirthdayGreetingYear === thisYear) {
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const birthday = new Date(thisYear, customer.birthDate.getMonth(), customer.birthDate.getDate());
@@ -190,7 +177,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
// Nur wenn heute oder in den letzten 7 Tagen (diff: 07)
if (diff < 0 || diff > 7) {
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const age = calculateAge(customer.birthDate, today);
@@ -199,7 +186,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
show: true,
isToday: diff === 0,
daysAgo: diff,
...baseInfo,
firstName: customer.firstName,
age,
};
}
@@ -214,124 +201,3 @@ export async function acknowledgeBirthdayGreeting(customerId: number): Promise<v
data: { lastBirthdayGreetingYear: thisYear },
});
}
/**
* Setzt den Gruß-Marker zurück, damit das Modal beim nächsten Login wieder erscheint.
* (Für Mitarbeiter nützlich zum Debuggen und als Fallback wenn etwas schief ging.)
*/
export async function resetBirthdayGreeting(customerId: number): Promise<void> {
await prisma.customer.update({
where: { id: customerId },
data: { lastBirthdayGreetingYear: null },
});
}
/**
* Generiert den persönlichen Geburtstagsgruß-Text (Du/Sie-abhängig).
*/
export function buildBirthdayGreetingText(
customer: {
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
},
age: number,
): { subject: string; plain: string; html: string } {
const name = customer.useInformalAddress
? customer.firstName
: [customer.salutation, customer.lastName].filter(Boolean).join(' ') || customer.firstName;
const du = customer.useInformalAddress;
const pronoun = du ? 'dir' : 'Ihnen';
const possessive = du ? 'deinem' : 'Ihrem';
const yourLower = du ? 'dein' : 'Ihr';
const subject = du
? `Alles Gute zum Geburtstag, ${customer.firstName}! 🎉`
: 'Herzlichen Glückwunsch zum Geburtstag 🎉';
// Plain-Text ohne Emojis, damit WhatsApp/Telegram/Signal-URL-Handler nicht stolpern
const plain = [
`Herzlichen Glückwunsch, ${name}!`,
'',
age > 0
? `Alles Gute zu ${possessive} ${age}. Geburtstag!`
: `Alles Gute zu ${possessive} Geburtstag!`,
'',
`Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr.`,
'',
'Herzliche Grüße',
'Hacker-Net Telekommunikation',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 50%, #6366f1 100%); padding: 40px 20px; text-align: center; border-radius: 12px 12px 0 0;">
<div style="font-size: 64px; margin-bottom: 8px;">🎉🎂🎈</div>
</div>
<div style="padding: 32px; background: #ffffff; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<h2 style="color: #1f2937; margin-top: 0;">Herzlichen Glückwunsch, ${name}!</h2>
<p style="color: #4b5563; font-size: 16px; line-height: 1.6;">
${age > 0 ? `Alles Gute zu ${possessive} <strong>${age}. Geburtstag</strong>!` : `Alles Gute zu ${possessive} Geburtstag!`}
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Herzliche Grüße<br>
<strong>Hacker-Net Telekommunikation</strong><br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
</div>
`;
return { subject, plain, html };
}
/**
* Lädt die für den Gruß benötigten Kundendaten inkl. aktuellem Alter heute.
*/
export async function getBirthdayGreetingData(customerId: number): Promise<{
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
age: number;
} | null> {
const c = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
email: true,
phone: true,
mobile: true,
birthDate: true,
},
});
if (!c?.birthDate) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const age = calculateAge(c.birthDate, today);
return {
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
email: c.email,
phone: c.phone,
mobile: c.mobile,
age,
};
}
@@ -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 };
-245
View File
@@ -765,251 +765,6 @@ export async function createFollowUpContract(previousContractId: number) {
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
export async function getContractPassword(id: number): Promise<string | null> {
const contract = await prisma.contract.findUnique({
@@ -129,35 +129,3 @@ export async function createNewContractFromPredecessorEntry(
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,
});
}
-3
View File
@@ -136,7 +136,6 @@ export async function updateCustomer(
data: {
type?: CustomerType;
salutation?: string;
useInformalAddress?: boolean;
firstName?: string;
lastName?: string;
companyName?: string;
@@ -149,8 +148,6 @@ export async function updateCustomer(
businessRegistration?: string;
commercialRegister?: string;
notes?: string;
autoBirthdayGreeting?: boolean;
autoBirthdayChannel?: string | null;
}
) {
return prisma.customer.update({
@@ -74,30 +74,11 @@ export interface CreateProviderConfigData {
// System-E-Mail
systemEmailAddress?: string;
systemEmailPassword?: string;
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln", "Meine-Firma")
customerEmailLabel?: string;
isActive?: boolean;
isDefault?: boolean;
}
// Validiert Domain-Format (z.B. stressfrei-wechseln.de, mail.beispiel.com)
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
function validateDomain(domain: string | undefined): string {
if (!domain || !domain.trim()) {
throw new Error('Domain ist erforderlich');
}
const normalized = domain.trim().toLowerCase();
if (!DOMAIN_REGEX.test(normalized)) {
throw new Error(`Ungültige Domain: "${domain}". Format: name.tld (z.B. meine-firma.de)`);
}
return normalized;
}
export async function createProviderConfig(data: CreateProviderConfigData) {
// Domain validieren
const validatedDomain = validateDomain(data.domain);
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
@@ -119,14 +100,13 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
apiKey: data.apiKey || null,
username: data.username || null,
passwordEncrypted,
domain: validatedDomain,
domain: data.domain,
defaultForwardEmail: data.defaultForwardEmail || null,
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
customerEmailLabel: data.customerEmailLabel || null,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -152,14 +132,13 @@ export async function updateProviderConfig(
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
if (data.username !== undefined) updateData.username = data.username || null;
if (data.domain !== undefined) updateData.domain = validateDomain(data.domain);
if (data.domain !== undefined) updateData.domain = data.domain;
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = data.customerEmailLabel?.trim() || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
@@ -492,39 +471,6 @@ export async function getProviderDomain(): Promise<string | null> {
return config?.domain || null;
}
/**
* Label aus der Domain ableiten, z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln".
* Nimmt den Hauptteil bis zum ersten Punkt, trennt an "-" und kapitalisiert jeden Teil.
*/
export function deriveLabelFromDomain(domain: string | null | undefined): string {
if (!domain) return 'Kunden-E-Mail';
const mainPart = domain.split('.')[0] || domain;
return mainPart
.split('-')
.map((s) => (s.length === 0 ? '' : s.charAt(0).toUpperCase() + s.slice(1)))
.join('-');
}
/**
* Öffentliche Provider-Einstellungen (Domain + Label) für UI.
* Kein auth-geschütztes Geheimnis, nur damit die Frontend-Labels stimmen.
*/
export async function getProviderPublicSettings(): Promise<{
domain: string | null;
customerEmailLabel: string;
customerEmailLabelIsCustom: boolean;
}> {
const config = await getActiveProviderConfig();
const domain = config?.domain ?? null;
const customLabel = config?.customerEmailLabel?.trim();
return {
domain,
customerEmailLabel: customLabel && customLabel.length > 0 ? customLabel : deriveLabelFromDomain(domain),
customerEmailLabelIsCustom: !!(customLabel && customLabel.length > 0),
};
}
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
function createProviderFromFormData(data: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
@@ -1,463 +0,0 @@
/**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import AdmZip from 'adm-zip';
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 {
version: 1;
exportedAt: string;
counts: {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
appSettings: number;
};
}
export interface ProviderExport {
name: string;
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
export interface PdfTemplateExport {
name: string;
description: string | null;
providerName: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix: string | null;
maxPhoneFields: number | null;
isActive: boolean;
// Datei wird separat im ZIP abgelegt unter pdf-templates/<name>.pdf
pdfFilename: string;
}
/**
* Sammelt alle Katalog-Daten aus der DB.
*/
export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
await Promise.all([
prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } },
orderBy: { name: 'asc' },
}),
prisma.cancellationPeriod.findMany({ orderBy: { code: 'asc' } }),
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: '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 {
providers: providers.map<ProviderExport>((p) => ({
name: p.name,
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
cancellationPeriods: cancellationPeriods.map((c) => ({
code: c.code,
description: c.description,
isActive: c.isActive,
})),
contractDurations: contractDurations.map((d) => ({
code: d.code,
description: d.description,
isActive: d.isActive,
})),
contractCategories: contractCategories.map((c) => ({
code: c.code,
name: c.name,
icon: c.icon,
color: c.color,
sortOrder: c.sortOrder,
isActive: c.isActive,
})),
pdfTemplates: pdfTemplates.map<PdfTemplateExport>((t) => {
// fieldMapping ist im DB als String abgelegt zu Objekt parsen
let fieldMapping: any = {};
try {
fieldMapping = JSON.parse(t.fieldMapping);
} catch {
fieldMapping = {};
}
return {
name: t.name,
description: t.description,
providerName: t.providerName,
originalName: t.originalName,
fieldMapping,
phoneFieldPrefix: t.phoneFieldPrefix,
maxPhoneFields: t.maxPhoneFields,
isActive: t.isActive,
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
};
}),
appSettings: appSettings as AppSettingExport[],
};
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_').replace(/_+/g, '_');
}
/**
* Erstellt ein ZIP-Archiv mit allen Factory-Defaults.
* Die PDF-Dateien werden aus dem uploads-Verzeichnis gelesen.
*/
export async function exportFactoryDefaults(): Promise<Buffer> {
const data = await collectFactoryDefaults();
const manifest: FactoryDefaultsManifest = {
version: 1,
exportedAt: new Date().toISOString(),
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
appSettings: data.appSettings.length,
},
};
return new Promise<Buffer>((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', reject);
// JSON-Dateien vorbereiten
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
archive.append(JSON.stringify(data.providers, null, 2), {
name: 'providers/providers.json',
});
archive.append(JSON.stringify(data.cancellationPeriods, null, 2), {
name: 'contract-meta/cancellation-periods.json',
});
archive.append(JSON.stringify(data.contractDurations, null, 2), {
name: 'contract-meta/contract-durations.json',
});
archive.append(JSON.stringify(data.contractCategories, null, 2), {
name: 'contract-meta/contract-categories.json',
});
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
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)
const uploadsRoot = path.join(process.cwd(), 'uploads');
(async () => {
try {
const templateRows = await prisma.pdfTemplate.findMany({
select: { name: true, templatePath: true },
});
const pathByName = new Map(templateRows.map((r) => [r.name, r.templatePath]));
for (const t of data.pdfTemplates) {
const storedPath = pathByName.get(t.name);
if (!storedPath) continue;
const relPath = storedPath.startsWith('/uploads/')
? storedPath.substring('/uploads/'.length)
: storedPath;
const absPath = path.join(uploadsRoot, relPath);
if (fs.existsSync(absPath)) {
archive.file(absPath, { name: `pdf-templates/${t.pdfFilename}` });
} else {
console.warn(`[factoryDefaults] PDF-Datei nicht gefunden: ${absPath}`);
}
}
archive.finalize();
} catch (err) {
reject(err);
}
})();
});
}
// ============================================================
// 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;
}
}
+28 -153
View File
@@ -14,36 +14,6 @@ export interface ImapCredentials {
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
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;
}
/**
* TLS-Optionen für IMAP-Verbindungen zusammenbauen.
* Wenn `allowSelfSignedCerts` aktiv ist, werden zusätzlich ältere TLS-Versionen
* (TLS 1.0+) und legacy Cipher-Suites erlaubt hilfreich bei älteren Mailservern,
* die sonst den Socket sofort nach Connect schließen.
*/
function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown> | undefined {
const encryption = credentials.encryption ?? 'SSL';
if (encryption === 'NONE') return undefined;
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
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) {
options.minVersion = 'TLSv1';
options.ciphers = 'DEFAULT:@SECLEVEL=0';
}
return options;
}
export interface FetchedEmail {
@@ -136,7 +106,7 @@ export async function fetchEmails(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
// Debug-Logging
@@ -290,7 +260,7 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -306,36 +276,9 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
// Ignorieren
}
// Rohes Error-Objekt loggen, damit wir ImapFlow-spezifische Felder sehen
console.error('[testImapConnection] Raw error:', error);
if (error && typeof error === 'object') {
const e = error as any;
console.error('[testImapConnection] Details:', {
code: e.code,
response: e.response,
responseStatus: e.responseStatus,
responseText: e.responseText,
authenticationFailed: e.authenticationFailed,
serverResponseCode: e.serverResponseCode,
});
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
const e = error as any;
// ImapFlow-spezifische Details durchreichen wenn vorhanden
if (e.authenticationFailed) {
throw new Error(
`IMAP-Authentifizierung fehlgeschlagen${e.response ? `: ${e.response}` : ''}`,
);
}
if (e.response || e.responseText) {
throw new Error(
`IMAP ${e.responseStatus || 'Fehler'}: ${e.response || e.responseText}`,
);
}
if (msg.includes('authentication') || msg.includes('login')) {
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
@@ -382,7 +325,7 @@ export async function getHighestUid(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -417,41 +360,6 @@ export async function fetchAttachment(
uid: number,
attachmentFilename: string,
folder: string = 'INBOX'
): Promise<EmailAttachmentData | null> {
// Bei transienten Netzwerkfehlern automatisch bis zu 2x retry
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await fetchAttachmentInner(credentials, uid, attachmentFilename, folder);
} catch (err) {
lastError = err;
const msg = err instanceof Error ? err.message.toLowerCase() : '';
const isTransient =
msg.includes('socket disconnected') ||
msg.includes('econnreset') ||
msg.includes('etimedout') ||
msg.includes('socket hang up') ||
msg.includes('network socket');
if (!isTransient || attempt === 3) {
throw err;
}
console.warn(
`[fetchAttachment] Versuch ${attempt}/3 fehlgeschlagen (transient), retry in ${attempt * 500}ms:`,
msg,
);
await new Promise((r) => setTimeout(r, attempt * 500));
}
}
throw lastError;
}
async function fetchAttachmentInner(
credentials: ImapCredentials,
uid: number,
attachmentFilename: string,
folder: string = 'INBOX'
): Promise<EmailAttachmentData | null> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
@@ -466,78 +374,45 @@ async function fetchAttachmentInner(
pass: credentials.password,
},
logger: false,
// Timeouts gegen hängende Verbindungen
socketTimeout: 30000,
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
console.log(`[fetchAttachment] Host: ${credentials.host}:${credentials.port} | User: ${credentials.user} | Folder: ${folder} | UID: ${uid} | File: ${attachmentFilename} | AllowSelfSigned: ${credentials.allowSelfSignedCerts}`);
try {
await client.connect();
// Ordner öffnen bei Fehler alle verfügbaren Ordner listen für Debugging
try {
await client.mailboxOpen(folder);
} catch (folderErr) {
console.error(`[fetchAttachment] mailboxOpen('${folder}') failed:`, folderErr);
try {
const list = await client.list();
const available = list.map((m) => m.path).join(', ');
console.error(`[fetchAttachment] Verfügbare Ordner: ${available}`);
throw new Error(
`Ordner '${folder}' nicht gefunden. Verfügbar: ${available}`,
);
} catch (listErr) {
throw folderErr;
}
}
await client.mailboxOpen(folder);
// E-Mail per UID abrufen
let attachment: EmailAttachmentData | null = null;
let foundMessage = false;
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
try {
for await (const message of client.fetch(uid.toString(), {
source: true,
}, { uid: true })) {
foundMessage = true;
if (!message.source) continue;
for await (const message of client.fetch(uid.toString(), {
source: true,
}, { uid: true })) {
if (!message.source) continue;
// E-Mail parsen
const parsed = await simpleParser(message.source);
// E-Mail parsen
const parsed = await simpleParser(message.source);
// Anhang suchen
if (parsed.attachments) {
for (const att of parsed.attachments) {
const filename = att.filename || 'unnamed';
if (filename === attachmentFilename) {
attachment = {
filename,
content: att.content,
contentType: att.contentType || 'application/octet-stream',
size: att.size,
};
break;
}
// Anhang suchen
if (parsed.attachments) {
for (const att of parsed.attachments) {
const filename = att.filename || 'unnamed';
if (filename === attachmentFilename) {
attachment = {
filename,
content: att.content,
contentType: att.contentType || 'application/octet-stream',
size: att.size,
};
break;
}
}
}
} catch (fetchErr) {
console.error(`[fetchAttachment] fetch(UID ${uid}) failed:`, fetchErr);
throw new Error(
`Nachricht mit UID ${uid} konnte nicht geladen werden (${fetchErr instanceof Error ? fetchErr.message : 'unbekannter Fehler'}). Möglicherweise wurde sie im IMAP-Postfach verschoben oder gelöscht.`,
);
}
if (!foundMessage) {
console.warn(`[fetchAttachment] Keine Nachricht mit UID ${uid} in Ordner '${folder}' gefunden`);
}
await client.logout();
@@ -584,7 +459,7 @@ export async function appendToSent(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
@@ -670,7 +545,7 @@ export async function fetchAttachmentList(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -762,7 +637,7 @@ export async function moveToTrash(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -835,7 +710,7 @@ export async function restoreFromTrash(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -906,7 +781,7 @@ export async function permanentDelete(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -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(),
};
}
+2 -44
View File
@@ -15,10 +15,6 @@ export interface SmtpCredentials {
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
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
@@ -53,16 +49,6 @@ export interface EmailLogContext {
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
export async function sendEmail(
credentials: SmtpCredentials,
@@ -70,21 +56,6 @@ export async function sendEmail(
params: SendEmailParams,
logContext?: EmailLogContext
): 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
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
@@ -98,7 +69,7 @@ export async function sendEmail(
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
requireTLS?: boolean;
connectionTimeout: number;
@@ -120,16 +91,6 @@ export async function sendEmail(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
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) {
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
transportOptions.tls.minVersion = 'TLSv1';
transportOptions.tls.ciphers = 'DEFAULT:@SECLEVEL=0';
}
} else {
// Keine Verschlüsselung: STARTTLS ignorieren
transportOptions.ignoreTLS = true;
@@ -312,7 +273,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
connectionTimeout: number;
greetingTimeout: number;
@@ -330,9 +291,6 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
if (credentials.servername) {
transportOptions.tls.servername = credentials.servername;
}
} else {
transportOptions.ignoreTLS = true;
}
-271
View File
@@ -1,271 +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;
}
/**
* 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',
);
}
-152
View File
@@ -1,152 +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',
] 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.
*/
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = { ...customer };
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
// 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;
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
*/
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;
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 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);
}
-107
View File
@@ -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 };
}
+76
View File
@@ -0,0 +1,76 @@
# 📋 OpenCRM Todo-Liste
---
## 🔜 Offen
### Email Log & System testen
- Senden testen
- Empfangen testen
### Security System testen
### Email → Vertragsdokumente
Wenn eine Email einem Vertrag zugeordnet ist:
- Anhänge auch in Vertragsdokumente speichern
- Rechnungen wie Kündigungsdokumente behandeln
---
## ✅ Erledigt
- [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)
View File
View File
View File
View File
+11 -97
View File
@@ -1,19 +1,4 @@
# OpenCRM komplettes Setup: MariaDB + Backend/Frontend + Adminer
# Konfiguration über ./.env (siehe ./.env.example)
#
# Quick-Start (Compose v2):
# cp .env.example .env # Werte anpassen (Secrets rotieren!)
# docker compose up -d # erstes Mal: holt Images, baut Backend, startet alles
# Quick-Start (Compose v1, Legacy):
# docker-compose up -d
#
# Browser:
# http://localhost:${OPENCRM_PORT} # CRM
# http://localhost:${ADMINER_PORT} # DB-UI
#
# Daten liegen alle unter ./data/* Bind-Mounts statt Volumes (auf Wunsch).
#version: '3.8'
version: '3.8'
services:
db:
@@ -21,91 +6,20 @@ services:
container_name: opencrm-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: opencrm
MYSQL_USER: opencrm
MYSQL_PASSWORD: opencrm123
ports:
# Externe Erreichbarkeit für lokale DB-Tools (TablePlus etc.).
# Auf 127.0.0.1 binden kein public exposure.
- "127.0.0.1:${DB_PORT:-3306}:3306"
- "3306:3306"
volumes:
- ${DB_DATA_DIR:-./data/db}:/var/lib/mysql
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 20s
start_period: 10s
interval: 10s
timeout: 5s
retries: 5
retries: 3
opencrm:
build:
context: .
dockerfile: backend/Dockerfile
container_name: opencrm-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
# DATABASE_URL wird vom entrypoint.sh aus den DB_*-Komponenten gebaut
# mit encodeURIComponent für Passwörter mit Sonderzeichen ($, !, #, @, :,
# / etc.). KEIN root für die App, sondern der App-User ${DB_USER}, den
# MariaDB beim ersten Start automatisch mit GRANT ALL PRIVILEGES auf
# ${DB_NAME}.* anlegt (über MARIADB_USER/MARIADB_PASSWORD).
DB_HOST: db
DB_PORT: 3306
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
NODE_ENV: production
PORT: 3001
LISTEN_ADDR: 0.0.0.0
CORS_ORIGINS: ${CORS_ORIGINS:-}
HTTPS_ENABLED: ${HTTPS_ENABLED:-false}
RUN_SEED: ${RUN_SEED:-false}
ports:
- "${OPENCRM_PORT:-3010}:3001"
volumes:
# Bind-Mounts für persistente Daten unter ./data/
- ${UPLOADS_DIR:-./data/uploads}:/app/uploads
- ${FACTORY_DEFAULTS_DIR:-./data/factory-defaults}:/app/factory-defaults
- ${BACKUPS_DIR:-./data/backups}:/app/prisma/backups
adminer:
image: adminer:latest
container_name: opencrm-adminer
restart: unless-stopped
depends_on:
- db
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: ${ADMINER_DESIGN:-pepa-linha}
# Adminers offizieller entrypoint linkt nur Designs, deren CSS exakt
# `adminer.css` heißt. Manche Designs (dracula, adminer-dark) haben aber
# `adminer-dark.css`. Wir machen den Symlink generisch: erstes .css im
# gewählten Design wird verlinkt. Danach übergeben wir an den originalen
# entrypoint.sh.
entrypoint:
- /bin/sh
- -c
- >
cd /var/www/html;
if [ -n "$$ADMINER_DESIGN" ] && [ -d "designs/$$ADMINER_DESIGN" ]; then
CSS=$$(ls designs/$$ADMINER_DESIGN/*.css 2>/dev/null | head -1);
if [ -n "$$CSS" ]; then
ln -sf "$$CSS" adminer.css;
touch .adminer-init;
echo "[adminer-bootstrap] Theme aktiv: $$ADMINER_DESIGN -> $$CSS";
else
echo "[adminer-bootstrap] Design '$$ADMINER_DESIGN' enthält kein CSS nutze Default";
fi;
fi;
exec entrypoint.sh docker-php-entrypoint "$$@"
- --
command: ["php", "-S", "[::]:8080", "-t", "/var/www/html"]
ports:
- "127.0.0.1:${ADMINER_PORT:-8090}:8080"
volumes:
mariadb_data:
-352
View File
@@ -1,352 +0,0 @@
# 🛡️ Security-Hardening die ganze Geschichte
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
Code-Review und öffentlichem Deployment durchlaufen hat.
Format pro Runde: **Was war kaputt****Wie es gefixt wurde** → wo möglich
**Live-Test-Resultate**.
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
---
## 📊 Live-verifizierte Tests im Überblick
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
durchgespielt statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
brauchten Live-Tests.
### Runde 4 IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
| Endpoint | Vorher | Nachher |
| -------------------------------------------- | ------------------------------- | ---------------------------- |
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
### Runde 5 DSGVO-GAU + Timing-Side-Channel
| Test | Vorher | Nachher |
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
### Runde 6 Customer-Liste-Leak + XFF-Bypass
| Test | Vorher | Nachher |
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
### Runde 7 SSRF + Logout
| Test | Vorher | Nachher |
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
### Runde 8 DNS-Rebinding + Per-File-Ownership
| Test | Resultat |
| ----------------------------------------------------- | --------------------------------------------- |
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
| Ohne Token | ✅ HTTP 401 |
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
### Runde 9 Vorher überprüft, Dependency-Audit, Audit-Chain
| Test | Resultat |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| `From`-Address-Header-Injection (CRLF in fromAddress) | ✅ bereits in Stage 3 abgefangen (`containsCRLF`) |
| `npm audit` (initial) | 9 Vulns (4× high) |
| `npm audit fix` | ✅ 8 transitive Vulns gefixt |
| nodemailer breaking-update auf 8.x | 📋 als v1.1-Item dokumentiert |
| Audit-Log Hash-Chain vor `rehashAll` | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) |
| Audit-Log Hash-Chain nach `rehashAll` | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) |
| Authenticated Rate-Limit (50 parallele Requests) | 🟡 keiner DoS-Schutz vom Reverse-Proxy übernehmen |
| Frontend `localStorage` Token-Stealing-Vektor | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau |
---
## 🗂️ Runde-für-Runde
### Runde 1 Erste kritische Findings (statisches Review)
- CORS komplett offen → `CORS_ORIGINS` explizit
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
- XSS via Email-Body → DOMPurify mit strikter Config
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
- Body-Size-Limit 5 MB
### Runde 2 Deep-Dive (parallele Audit-Agents)
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
### Runde 3 Tiefer Dive (8 weitere Hardenings)
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
- IDOR PDF-Template-Generator → `canAccessContract`
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
- bcrypt cost 10 → 12 (OWASP 2026)
### Runde 4 Live-Tests gegen Dev-Server (Tabelle oben)
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
### Runde 5 Hack-Das-Ding-Audit
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
(`canAccessContract`).
### Runde 6 Tiefer Live-Pentest (Tabelle oben)
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For**`trust proxy = 'loopback'`
+ `LISTEN_ADDR=127.0.0.1` in Production
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
beide Fehlfälle uniform 403.
- Prisma-Error-Leaks generisch ersetzt.
### Runde 7 Letzter Schliff
- **SSRF-Schutz** in `test-connection` und `test-mail-access`
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
/ `portalTokenInvalidatedAt` auf jetzt.
### Runde 8 Loose Ends
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI ein
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
### Runde 10 Security-Monitoring + Alerting
Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
zumindest **gesehen** werden. Ergänzt:
- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
+ Indexen für schnelles Filter+Threshold-Detection.
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
(`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
(Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
- **Service `securityAlert.service.ts`** mit:
- **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
HIGH/5min → erzeugt synthetische CRITICAL-Events.
- **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
(debounced, max. 1× pro Stunde + IP).
- **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
in einer Mail (wenn `monitoringDigestEnabled = true`).
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
Auto-Refresh alle 30s.
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
alle hinter `settings:read` / `settings:update`.
Live-verifiziert (1. Mai 2026):
| Test | Resultat |
| --------------------------------------------------- | --------------------------------------------------- |
| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt |
| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event |
| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events |
| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event |
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt |
| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt |
| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt |
### Runde 9 Diminishing-Returns-Runde
Nichts Kritisches mehr gefunden. Liefert noch:
- **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability
(lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene
nodemailer-Vuln braucht Major-Update (v6 → v8) v1.1-Item.
- **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch
frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll`
repariert; integrity-check verifiziert die Chain wieder. Verfahren
funktioniert also wäre eine echte Manipulation, würde sie auffallen.
- **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die
zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
atomar gelöscht zweiter Versuch findet keinen Token. ✅
---
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
- Path-Traversal in Backup-Name (Regex blockiert)
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
- Self-grant Vollmacht via `customers/X/representatives` → 403
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
---
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
`<a href>`-Downloads ohne JS v1.2-Item.
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
niedrig-Prio.
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
bei Validation-Fehler) Defense-in-Depth-Kandidat, aber nur Admin-
erreichbar.
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
erreichbar, niedrig-Prio).
- **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur
Login + Password-Reset rate-limited. Eingeloggte User können theoretisch
hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy
(Nginx/Plesk haben eigene Limits) nicht im App-Layer. Wenn nötig,
später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP).
- **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern,
XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via
Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System.
- **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen
(SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese
Felder nicht aus User-Input Risiko gering, Update breaking.
---
## 🚀 Production-Deployment-Checkliste
Vor dem öffentlichen Schalten muss in der Production-`.env`:
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
hart auf die echte Client-IP gesetzt wird (nicht angefügt) sonst
Rate-Limit-Bypass möglich.
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
durchklicken.
---
## 🔄 Lazy Password-Hash-Upgrade
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
Wochen nach Deployment.
---
## 🗨️ Lehre aus der Session
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
User-zugänglich sei war's nicht), übersehen aber, dass ein
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
Vor jedem Launch mit echten Tokens probieren.
---
## 📑 Commit-Historie
| Commit | Runde | Hauptthema |
| --------- | ------- | -------------------------------------------------------------- |
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
| `12b9abe` | 7 | SSRF-Schutz + Logout |
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) |
---
## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist:
1. **Mehrere Audit-Methoden durch** statisches Code-Review, parallele
Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓
2. **OWASP-Top-10 explizit getestet** Auth, Access-Control, Injection,
Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓
3. **Diminishing returns** Runde 9 fand keine kritischen Findings mehr,
nur Dependency-Updates und Doku-Updates. ✓
4. **Production-Deployment-Checkliste klar.** ✓
5. **Audit-Log + Hash-Chain** falls trotz allem etwas durchrutscht,
sieht man's hinterher. ✓
Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`),
neue Angriffsklassen, Server-Misconfig in Production, Social Engineering.
Dafür gibt's keine Code-Lösung nur Monitoring und Rotation der Secrets.
-231
View File
@@ -1,231 +0,0 @@
# Security-Review vor 1.0.0
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
> **Version 2** dieser Review wurde in 2 Runden durchgeführt.
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
Systematischer Review des Codebase mit Fokus auf Produktions-Hardening
vor öffentlichem Deployment (hinter HTTPS-Proxy).
## Gefundene Probleme & Fixes
### 🔴 KRITISCH (sofort gefixt)
#### 1. CORS komplett offen
**Vorher:** `app.use(cors())` jede Origin darf Requests senden.
**Risiko:** Fremde Websites können bei eingeloggtem User Requests mit dessen
JWT durchführen (wenn Token in Cookies wäre bei localStorage weniger relevant,
aber trotzdem schlechte Praxis).
**Fix:** CORS nur für explizit konfigurierte Origins (via `CORS_ORIGINS` ENV),
in Production per Default komplett aus (Frontend läuft unter gleicher Origin).
#### 2. Keine Security-Headers (Helmet fehlt)
**Vorher:** Keine HTTP-Security-Headers gesetzt.
**Risiko:** XSS, Clickjacking, MIME-Sniffing, Missing HSTS.
**Fix:** `helmet`-Middleware aktiviert setzt automatisch:
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (in HTTPS),
Cross-Origin-Resource-Policy.
#### 3. JWT-Fallback-Secret
**Vorher:** `jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret')`
**Risiko:** Wenn `.env` kaputt ist oder Secret leer → bekannter String
"fallback-secret" → **Tokens können gefälscht werden!**
**Fix:** Beim Server-Start wird geprüft, dass JWT_SECRET mindestens 32 Zeichen lang
und ENCRYPTION_KEY exakt 64 Hex-Zeichen hat. Sonst Abbruch mit klarer Fehlermeldung.
Fallback wurde aus dem Code entfernt.
#### 4. IDOR bei sensiblen Contract-Endpoints
**Vorher:** Portal-Kunden haben `contracts:read` Permission → können über
geratene IDs auf **fremde** Daten zugreifen:
- `GET /contracts/:id/password` → Passwort im Klartext
- `GET /contracts/simcard/:id/credentials` → PIN/PUK
- `GET /contracts/:id/internet-credentials` → Internet-Passwort
- `GET /contracts/phonenumber/:id/sip-credentials` → SIP-Passwort
- `GET /contracts/:id/documents` → Vertragsdokumente
- `GET /contracts/:id/invoices` → Rechnungen
- `POST /contracts/:id/invoices` → Rechnung zu fremdem Vertrag hinzufügen
**Fix:** Neuer Helper `canAccessContract()` in `backend/src/utils/accessControl.ts`.
Wird in allen sensiblen Endpoints aufgerufen und prüft:
- Mitarbeiter/Admin → OK
- Portal-Kunde + eigener Vertrag → OK
- Portal-Kunde + vertretener Kunde MIT gültiger Vollmacht → OK
- Sonst 403 Forbidden
#### 5. XSS via Email-Body
**Vorher:** `<div dangerouslySetInnerHTML={{ __html: email.htmlBody }} />`
**Risiko:** Ein Angreifer sendet Mail mit `<script>fetch('/api/...')`
wird im Browser des Mitarbeiters ausgeführt → JWT-Token-Diebstahl möglich.
**Fix:** DOMPurify sanitized `htmlBody` vor dem Rendern:
- Verbietet: script, style, iframe, object, embed, form, inline-handler
- Erlaubt: normale Formatierung, Bilder, Links
- Zusätzlich: target=_blank damit Links neue Tabs öffnen
#### 6. Customer-API leakt Passwort-Hashes + Reset-Tokens
**Vorher:** `getCustomer` / `getCustomers` gab alle Felder zurück inklusive:
- `portalPasswordHash` (bcrypt)
- `portalPasswordEncrypted` (symmetrisch, entschlüsselbar mit Key)
- `portalPasswordResetToken` (gültig 2h, damit könnte man das Passwort zurücksetzen)
**Fix:** Zentrale Sanitizer-Helper in `backend/src/utils/sanitize.ts`:
- `sanitizeCustomer` → entfernt Hash + Reset-Token
- `sanitizeCustomerStrict` → zusätzlich ohne Encrypted-Passwort
(für Nicht-Admin-Rollen)
- Im `getCustomer`/`getCustomers` angewendet: Admins sehen encrypted
(um Passwort in UI anzeigen zu können), alle anderen nicht.
### 🟡 WICHTIG (gefixt)
#### 7. Portal-JWT-Invalidation fehlte
**Vorher:** Nach einem Portal-Passwort-Reset blieben alte JWTs bis zum Ablauf (7d) gültig.
**Risiko:** Wenn ein Angreifer einen Token geklaut hat, konnte der Kunde das
Passwort zwar ändern, aber der Angreifer blieb eingeloggt.
**Fix:** Neues Feld `Customer.portalTokenInvalidatedAt` analog zu
`User.tokenInvalidatedAt`. Wird bei Portal-Passwort-Reset auf `now()` gesetzt.
Auth-Middleware prüft bei Portal-Sessions diesen Timestamp gegen `token.iat`.
#### 8. express.json() ohne Size-Limit
**Vorher:** Default 100KB aber unklar und nicht explizit.
**Fix:** `express.json({ limit: '5mb' })` deckt normale API-Bodies mit
eingebetteten Base64-Attachments ab, blockt aber DoS-Versuche mit 100MB-Payloads.
## Runde 2: Deep-Dive mit Audit-Agents (alle kritischen gefixt)
### 🔴 Kritisch
#### 9. Zip-Slip im Backup-Upload
**Vorher:** `zip.extractAllTo(finalBackupDir, true)` in
`backup.service.ts` extrahiert ZIP-Dateien ohne Validierung der Entry-Pfade.
**Risiko:** Ein Angreifer lädt ein bösartiges ZIP hoch mit Entries wie
`../../../etc/crontab` → Server-Filesystem-Overwrite, Root-Escalation möglich.
**Fix:** ZIP-Entries werden jetzt einzeln durchlaufen. Jeder `entry.entryName`
wird `path.resolve`-normalisiert und geprüft ob der Zielpfad innerhalb des
Backup-Verzeichnisses bleibt. Absolute Pfade + Null-Bytes werden abgelehnt.
#### 10. Mass Assignment bei Customer/User
**Vorher:** `updateCustomer`, `createCustomer`, `updateUser`, `createUser`
haben `req.body` direkt oder via Spread an Prisma-`update/create` gereicht.
**Risiko:**
- Ein Angreifer mit `customers:update`-Permission konnte `portalPasswordHash`
(bcrypt-Hash!), `portalPasswordResetToken`, `consentHash`, `customerNumber`
direkt setzen
- Bei User-Update: `roleIds: [1]` übergeben → **Privilege Escalation** zum Admin
- `isActive: false` → andere User deaktivieren
**Fix:** Neue Whitelist-Helper `pickCustomerUpdate/Create`, `pickUserUpdate/Create`
in `utils/sanitize.ts`. Nur explizit erlaubte Felder gehen an Prisma durch.
Kritische Felder wie `portalPasswordHash`, `customerNumber`, `id`, `createdAt`,
`consentHash` sind **nicht** übernehmbar.
#### 11. IDOR bei weiteren sensiblen Endpoints
Nach der ersten Runde kam der Agent auf **13 weitere IDOR-Stellen**:
- `GET /meters/:meterId/readings` → fremde Zählerstände
- `GET /emails/:emailId/attachments/*` → fremde Email-Anhänge
- `GET /customers/:customerId/emails` → fremde Email-Inhalte (CachedEmail)
- `GET /contracts/:contractId/emails` → fremde Email-Inhalte per Vertrag
- `GET /emails/:id` → einzelne Email lesen
- `GET /stressfrei-emails/:id` → leakt `emailPasswordEncrypted`
- weitere…
**Fix:** `utils/accessControl.ts` deutlich ausgebaut um:
- `canAccessAddress`
- `canAccessBankCard`
- `canAccessIdentityDocument`
- `canAccessMeter`
- `canAccessStressfreiEmail`
- `canAccessCachedEmail`
Diese Helper laden die Ressource, prüfen die customerId und delegieren an
`canAccessCustomer` (Portal-Isolation + Vollmachten). In allen kritischen
Endpoints vor dem eigentlichen Datenzugriff aufgerufen.
Zusätzlich: `getEmail` (StressfreiEmail) filtert `emailPasswordEncrypted`
für Portal-Kunden explizit raus, selbst wenn sie zufällig Zugriff haben.
### 🟡 Wichtig
#### 12. Path-Traversal bei Backup-Namen
**Vorher:** `req.params.name` wurde direkt an `fs.readFile(path.join(backupDir, name))`
weitergereicht. `../` würde aus dem Backup-Verzeichnis ausbrechen.
**Fix:** Neuer `isValidBackupName()`-Guard: nur `[A-Za-z0-9_-]+`, kein `..`.
#### 13. Path-Traversal bei GDPR-Proof-Download
**Vorher:** `path.join(uploads, request.proofDocument)` ohne Validation.
Wenn ein Angreifer den `proofDocument`-Pfad in der DB manipulieren könnte
(z.B. über Mass-Assignment das haben wir aber oben gefixt), wäre arbitrary
file download möglich.
**Fix:** `path.resolve` auf den Pfad anwenden, prüfen dass das Ergebnis im
uploads-Verzeichnis liegt.
---
## Nicht kritische Findings (Empfehlungen für später)
### 🟢 Token in Query-Parameter
Für Attachment-Downloads/iframes wird das JWT als `?token=...` mitgegeben.
**Risiko:** Token landet in Server-Access-Logs, Browser-History, Referer-Headers.
**Mitigation aktuell:** JWT läuft nach 7d ab, und bei `password-reset` werden
alle Sessions gekickt.
**Bessere Lösung (später):** Kurzlebige Download-Tokens (5 Min) statt JWT direkt.
### 🟢 Upload: nur Browser-MIME-Check
Multer prüft nur den vom Browser gesendeten Content-Type. Ein Angreifer könnte
eine Shell mit `application/pdf` hochladen.
**Mitigation aktuell:**
- Uploads-Ordner hat keine Execute-Rechte (Linux-Standard)
- Dateien werden mit uniquem Namen + Original-Extension gespeichert
- Apache/Caddy served Uploads mit `Content-Disposition: attachment` inline (keine Ausführung)
**Besser (später):** Magic-Byte-Check via `file-type` npm-Paket.
### 🟢 `.env` in git history
Die initiale `.env` mit Demo-Secrets ist im ersten Commit eingecheckt.
**Risiko:** Wenn das Repo öffentlich wird, sind die Demo-Keys bekannt.
**Action:** Vor Öffentlich-Machen: `openssl rand -hex 64` für neuen JWT_SECRET
und `openssl rand -hex 32` für neuen ENCRYPTION_KEY in `.env.production`.
Optional: `git filter-repo` um `.env` aus History zu löschen.
## Deployment-Checkliste vor Go-Live
- [ ] **ENV-Vars setzen:**
- `JWT_SECRET` neu generiert (`openssl rand -hex 64`)
- `ENCRYPTION_KEY` neu generiert (`openssl rand -hex 32`)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://crm.meinedomain.de` (oder leer wenn SPA unter gleicher Origin)
- `PUBLIC_URL=https://crm.meinedomain.de` (für Reset-Links in E-Mails)
- [ ] **Helmet HSTS aktiv** (automatisch mit helmet + HTTPS hinter Caddy)
- [ ] **Dependencies aktuell:** `npm audit fix` lauen lassen
- [ ] **DB-User minimal:** Prod-User darf nur INSERT/UPDATE/DELETE/SELECT auf opencrm DB,
nicht DROP/ALTER/CREATE
- [ ] **Uploads-Ordner:** chmod 750, keine Execute-Rechte
- [ ] **Backup-Job:** Crontab mit täglichem `npm run db:backup`
- [ ] **Log-Rotation:** logrotate für Node-Process-Logs
- [ ] **Monitoring:** uptime-kuma o.Ä. auf `/api/health`
- [ ] **Reverse-Proxy (Caddy) setzt:**
- HSTS (mindestens 1 Jahr)
- automatisches SSL via Let's Encrypt
- Body-Size-Limit (Caddy-Config)
## Was getestet werden MUSS (vor öffentlichem Deployment)
1. **IDOR-Tests:** Als Portal-Kunde A einloggen, fremde IDs per URL/API probieren
→ alle müssen 403 geben (siehe TESTING.md)
2. **XSS-Tests:** Test-Mail mit `<script>alert(1)</script>` in HTML-Body senden,
im Email-Client öffnen → kein Alert
3. **Rate-Limit-Tests:** 11x falsch einloggen → muss blocken
4. **Password-Reset-Tests:** Reset-Link 2x nutzen → zweites Mal fehlschlägt
## Übersicht der Code-Änderungen
| Datei | Änderung |
|---|---|
| `backend/src/index.ts` | Helmet, CORS-Config, Body-Limit, ENV-Check beim Start |
| `backend/src/middleware/auth.ts` | JWT-Fallback raus, Portal-Token-Invalidation |
| `backend/src/services/auth.service.ts` | JWT-Fallback raus, `portalTokenInvalidatedAt` setzen |
| `backend/src/utils/accessControl.ts` | **NEU** `canAccessContract`, `canAccessCustomer` |
| `backend/src/utils/sanitize.ts` | **NEU** Sanitizer für Customer/User |
| `backend/src/controllers/contract.controller.ts` | IDOR-Schutz in 5 Endpoints |
| `backend/src/controllers/invoice.controller.ts` | IDOR-Schutz in 2 Endpoints |
| `backend/src/controllers/customer.controller.ts` | Sanitizer in getCustomer/getCustomers |
| `backend/prisma/schema.prisma` | `Customer.portalTokenInvalidatedAt` |
| `frontend/src/components/email/EmailDetail.tsx` | DOMPurify für htmlBody |
-172
View File
@@ -1,172 +0,0 @@
# Manueller Test-Katalog (v1.0.0)
Checklisten für manuelle Abnahmetests vor einem Release. Durchläuft die kritischen
Features Schritt für Schritt. Geschätzte Dauer für einen kompletten Durchlauf: ~60 Minuten.
---
## 🔐 Security-System
### 1. Login & Rate-Limiting
- [ ] **Mitarbeiter-Login** mit korrekten Credentials → Erfolgreich
- [ ] **Mitarbeiter-Login** mit falschem Passwort → Fehlermeldung "Ungültige Anmeldedaten"
- [ ] **Portal-Login** mit Kunden-E-Mail + Passwort → Erfolgreich ins Portal
- [ ] **Rate-Limit Login**: 10× falsch nacheinander versuchen → Nach 10. Versuch: "Zu viele
Login-Versuche. Bitte in 15 Minuten erneut versuchen."
- [ ] **Rate-Limit zählt erfolgreiche Logins nicht**: 5× falsch, dann 1× korrekt, dann wieder
5× falsch → immer noch erlaubt (weil erfolgreiche nicht zählen)
### 2. Passwort-Reset-Flow
- [ ] Auf Login-Seite: Link **"Passwort vergessen?"** sichtbar
- [ ] Klick öffnet `/password-reset/request`
- [ ] **Unbekannte E-Mail** eingeben → Trotzdem "E-Mail gesendet"-Bestätigung
(User-Enumeration-Schutz: Backend verrät nicht, ob Email existiert)
- [ ] **Bekannte Mitarbeiter-E-Mail** eingeben, Typ "Mitarbeiter" wählen → Reset-Mail geht raus
- [ ] Reset-Link aus Mail öffnen → Formular "Neues Passwort"
- [ ] **Passwörter stimmen nicht überein** → Fehlermeldung
- [ ] **Passwort < 6 Zeichen** → Fehlermeldung
- [ ] Gültiges Passwort setzen → "Passwort geändert", Redirect zu /login
- [ ] **Neuer Login** mit dem neuen Passwort → Funktioniert
- [ ] **Alter Session** (falls vorher eingeloggt) → Wurde gekickt, muss neu einloggen
- [ ] **Reset-Link ein zweites Mal nutzen** → Fehlermeldung "Ungültiger oder bereits verwendeter Link"
- [ ] **Reset-Link nach 2h+** nutzen → Fehlermeldung "Der Link ist abgelaufen"
- [ ] Gleicher Flow für **Portal-Kunden** (Typ "Kunde" wählen)
### 3. Rate-Limiting Passwort-Reset
- [ ] 5× Reset-Anfrage für dieselbe E-Mail senden → OK
- [ ] 6. Anfrage innerhalb einer Stunde → "Zu viele Passwort-Reset-Anfragen"
### 4. Berechtigungen (RBAC)
- [ ] **Admin**-User: kann Benutzer, Rollen, Einstellungen verwalten
- [ ] **Mitarbeiter**-User: kann Kunden/Verträge bearbeiten, **keine** User-Verwaltung
- [ ] **Mitarbeiter (Lesen)**: alles sichtbar, aber Buttons "Bearbeiten/Löschen" fehlen
- [ ] **Portal-Kunde**: sieht **nur eigene** Verträge + Daten, nicht die anderer Kunden
### 5. Portal-Isolation (wichtig für DSGVO)
- [ ] Als Portal-Kunde A einloggen
- [ ] In der URL manuell `/customers/999` eintippen (anderer Kunde) → Zugriff verweigert
- [ ] `/contracts/999` (fremder Vertrag) → Zugriff verweigert
- [ ] API-Call via Browser-Devtools `GET /api/customers/999` → 403 Forbidden
- [ ] Nur mit **Vollmacht** (RepresentativeAuthorization) kann Kunde A die Daten von B sehen
### 6. Session-Invalidation
- [ ] Eingeloggt als Mitarbeiter, in 2 Browser-Tabs
- [ ] Admin ändert Rolle des Mitarbeiters (User-Verwaltung)
- [ ] Nächster Request im Tab → wird zum Login redirectet (tokenInvalidatedAt greift)
### 7. Audit-Log
- [ ] Einstellungen → Audit-Protokoll öffnen
- [ ] Kunde anlegen → Eintrag mit Typ CREATE erscheint
- [ ] Kunden-Feld ändern (z.B. Geburtsort) → UPDATE-Eintrag mit Vorher/Nachher-Details
- [ ] Kunde löschen → DELETE-Eintrag
- [ ] DSGVO-Export herunterladen → EXPORT-Eintrag
- [ ] Filter nach Benutzer funktioniert
- [ ] Filter nach Aktion funktioniert
- [ ] Details-Modal zeigt Vorher/Nachher-Werte
### 8. DSGVO-Features
- [ ] Kunde einlegen → DSGVO-Tab
- [ ] Datenexport ausführen → JSON-Datei mit allen Daten des Kunden
- [ ] Löschanfrage erstellen → erscheint im DSGVO-Dashboard (Admin)
- [ ] Anonymisierung ausführen → Kundendaten werden anonymisiert, aktive Verträge bleiben
- [ ] Einwilligungen (alle 4 Typen) können pro Kunde gesetzt/widerrufen werden
- [ ] PDF-Upload als Alternative zu Online-Einwilligungen → Haken werden auf GRANTED gesetzt
- [ ] PDF löschen → Haken werden auf WITHDRAWN gesetzt
### 9. Verschlüsselte Credentials
- [ ] Ein Portal-Passwort (z.B. eines Anbieter-Zugangs) speichern
- [ ] In der DB (z.B. via Prisma Studio oder DB-GUI) nachschauen:
`portalPasswordEncrypted` darf **nicht im Klartext** sichtbar sein
- [ ] Portal-Passwort in der UI anzeigen → wird korrekt entschlüsselt
### 10. DSGVO-Einwilligung Mitarbeiter
- [ ] Als Mitarbeiter Kunde öffnen OHNE Einwilligung → Tabs Zähler/Verträge/Bankkarten/Ausweise/Email gesperrt
- [ ] Nach Einwilligung (alle 4 Haken oder PDF) → Tabs wieder zugänglich
---
## ✉ Email-Log-System
### 1. Email-Log-Seite öffnen
- [ ] Einstellungen → **Email-Protokoll** öffnen
- [ ] Statistik-Cards werden angezeigt: Gesamt, Erfolgreich, Fehlgeschlagen, Letzte 24h
- [ ] Log-Liste zeigt vergangene Versendungen
- [ ] Filter: **Erfolgreich/Fehlgeschlagen**
- [ ] Filter: **Kontext** (Datenschutz-Link, Vollmacht-Anfrage, Kunden-E-Mail, Geburtstagsgruß, Passwort-Reset)
- [ ] Suche nach Empfänger-E-Mail oder Betreff
- [ ] Pagination (Seite 1, 2, 3 …)
- [ ] Klick auf Eintrag → Details-Modal mit SMTP-Info, Message-ID, ggf. Fehlermeldung
### 2. Erfolgreicher Versand loggen alle Kontexte durchspielen
Für jeden Kontext: Aktion im CRM durchführen → danach im Email-Log prüfen,
ob der Eintrag erstellt wurde.
#### Datenschutz-Link
- [ ] Kunde öffnen → Einwilligungen/Datenschutz-Tab → "Link per Email senden"
- [ ] Log-Eintrag mit Kontext "Datenschutz-Link", Empfänger = Kunden-E-Mail, Status ✓
#### Vollmacht-Anfrage
- [ ] Kunde A mit Vertreter B → Vollmachten-Tab → "Anfrage per Email senden"
- [ ] Log-Eintrag mit Kontext "Vollmacht-Anfrage"
#### Kunden-E-Mail (via Email-Client)
- [ ] Kunde öffnen → Email-Tab → Mail verfassen und senden
- [ ] Log-Eintrag mit Kontext "Kunden-E-Mail"
#### Geburtstagsgruß (manuell)
- [ ] Kunde mit Geburtsdatum → Cake-Button → "Gruß jetzt senden" → Email
- [ ] Log-Eintrag mit Kontext "Geburtstagsgruß (manuell)"
#### Geburtstagsgruß (automatisch)
- [ ] Test-Kunde anlegen mit Geburtsdatum = heute
- [ ] Auto-Geburtstagsgruß aktivieren (Cake-Button → Checkbox + Kanal "Email")
- [ ] Server neu starten (Cron macht Catch-up nach 30s)
- [ ] Nach ~1 Min: Log-Eintrag mit Kontext "Geburtstagsgruß (automatisch)"
#### Passwort-Reset
- [ ] Logout → "Passwort vergessen?" → eigene Admin-E-Mail eingeben
- [ ] Log-Eintrag mit Kontext "Passwort-Reset"
### 3. Fehlgeschlagener Versand loggen
- [ ] Temporär SMTP-Passwort ungültig machen (Einstellungen → Provider bearbeiten)
- [ ] Beliebige E-Mail-Aktion auslösen (z.B. Datenschutz-Link senden)
- [ ] Log-Eintrag mit Status ✗ und Fehlermeldung ("SMTP-Authentifizierung fehlgeschlagen")
- [ ] Im Browser: Toast-Benachrichtigung mit Fehler erscheint
- [ ] Passwort wieder korrigieren
### 4. Details-Modal
- [ ] Klick auf erfolgreichen Eintrag: Zeigt Absender, Empfänger, Betreff, SMTP-Server/Port/Verschlüsselung, Message-ID, ggf. SMTP-Server-Antwort
- [ ] Klick auf fehlgeschlagenen Eintrag: Zusätzlich klare Fehlermeldung
- [ ] Kunden-Link im Modal: bei customerId → klickbar zum Kunden
### 5. Automatisches Logging
- [ ] SMTP-Server, Port, Verschlüsselung werden bei jedem Versand geloggt
- [ ] Kontext wird korrekt mitgegeben (nicht "unknown")
- [ ] triggeredBy zeigt die auslösende User-E-Mail (nicht "cron" bei manuellen Aktionen)
- [ ] Bei automatischen Aktionen (Cron): triggeredBy = "cron"
---
## Wie benutzen?
1. Diese Datei öffnen
2. Von oben nach unten durchklicken, Häkchen setzen (oder im Editor durch `- [x]`)
3. Gefundene Bugs in GitHub Issues oder direkt als Korrektur-Commit einbauen
Bei frisch geladener Datenbank (z.B. Dev-System): vorher 2-3 Test-Kunden mit vollständigen
Stammdaten + mindestens 1 Email-Provider-Konfiguration anlegen.
-327
View File
@@ -1,327 +0,0 @@
# 📋 OpenCRM Todo-Liste
---
## 🔜 Offen
### Manuelle Tests (vor Release durchklicken)
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
Einmal komplett durchlaufen vor v1.0.0-Release.
### 🚀 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] **🐛 PDF-Vorschau im PDF-Template-Editor lädt nicht**
- CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
der eigenen Resourcen, auch same-origin Browser zeigte je nach
Variante "Verbindung abgelehnt" oder CSP-Violation.
- Fix: `frame-ancestors 'self'` (statt `'none'`). App darf eigene
Resourcen embeden (z.B. die annotierte PDF-Vorschau), externe Sites
bleiben weiterhin gesperrt.
- [x] **🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)**
- `./factory-export.sh` zieht eine ZIP per API in `factory-exports/`
(gitignored Drop-Box).
- `./factory-import.sh [zip]` lädt die ZIP per API in eine andere Instanz
ohne Argument wählt es die jüngste ZIP automatisch.
- `./factory-import.sh --save-as-builtin` entpackt die ZIP zusätzlich nach
`backend/factory-defaults/` (vorher aufgeräumt). Damit landet sie beim
nächsten `docker-compose up --build` als Werkseinstellung im Image und
seedet frische DBs automatisch.
- Konfigurierbar per Env: `OPENCRM_URL`, `OPENCRM_EMAIL`,
`OPENCRM_PASSWORD` (sonst interaktive Abfrage).
- README-Abschnitt „Factory-Defaults: Stammdaten-Kataloge teilen"
komplett überarbeitet (drei Transport-Pfade, Auto-Seed, Whitelist).
- [x] **🚀 Auto-Seed: Werkseinstellungen beim Erst-Deploy**
- Inhalt von `backend/factory-defaults/` wird via Dockerfile als
`/app/factory-defaults-builtin/` ins Image gebrannt.
- Entrypoint spielt sie nach erfolgreichem Auto-Seed (frische DB) automatisch
via `tsx scripts/seed-factory-defaults.ts` ein steuerbar über
`FACTORY_DEFAULTS_DIR`.
- Damit bringen neue VMs sofort Anbieter, Tarife, PDF-Auftragsvorlagen +
Datenschutzerklärung/Impressum mit, ohne manuelles UI-/CLI-Import.
- Bestehende Installs werden NIE überschrieben (Trigger nur wenn der
Auto-Seed im selben Start-Lauf gelaufen ist).
- [x] **📦 Factory-Defaults: HTML-Templates + Import via UI**
- Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner,
Whitelist-geschützt andere AppSetting-Keys werden ignoriert).
- Import läuft jetzt auch über die UI (Einstellungen → Factory-Defaults →
„ZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und
wurde gleichermaßen um die HTML-Templates erweitert.
- Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen →
Import → Wert wieder vollständig hergestellt; Counts in Audit-Log.
- [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
- Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
`hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen
DSGVO/Developer waren in der UI nicht zuweisbar (Checkbox ohne Wirkung).
- Beide Felder zur Whitelist hinzugefügt + Audit-Log liest die Pre-Werte
jetzt aus den geladenen Rollen (kein False-Positive-Change mehr).
- [x] **🔒 HTTPS-only-Header per Flag (`HTTPS_ENABLED`)**
- HSTS + `upgrade-insecure-requests` (CSP) sperrten den Browser bei
direktem `http://ip:port`-Zugriff aus (`ERR_SSL_PROTOCOL_ERROR`).
- Beide Header default OFF, kommen nur mit `HTTPS_ENABLED=true` (sobald
TLS-Reverse-Proxy davor steht).
- [x] **🗃️ Prisma-Migrations-System (statt `db push`)**
- Initial-Migration `0_init` aus aktuellem Schema generiert
(`prisma migrate diff --from-empty --to-schema-datamodel`).
- 24 alte gedriftete Migrations gelöscht frischer Start.
- `migration_lock.toml` für MySQL hinzugefügt.
- Container-Entrypoint umgebaut:
- Auto-Baseline-Detection: bestehende DB ohne `_prisma_migrations`
`migrate resolve --applied 0_init` läuft automatisch.
- Statt `db push --accept-data-loss` jetzt `migrate deploy` (idempotent,
datenerhaltend, keine stillen DROPs mehr).
- Neuer npm-Script `schema:sync` (lokal/Dev): legt automatisch eine
versionierte Migration mit Zeitstempel-Namen an
(`prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)`).
- Workflow ab jetzt: schema.prisma ändern → `npm run schema:sync`
Migration committen → Push → Container-Restart wendet sie automatisch an.
- [x] **🔄 Automatische Vertrags-Status-Übergänge**
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
`status=ACTIVE` und `endDate < heute``EXPIRED` (mit Audit-Log).
- Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`):
wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log).
Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute),
wird direkt als `cancellationConfirmationDate` gespeichert.
Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er
für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt
aber `cancellationConfirmationOptionsDate` analog.
- Beim Upload einer `Lieferbestätigung` (ContractDocument via direkt-Upload
oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` → auf `ACTIVE`
setzen + `startDate` auf das erfasste Lieferdatum (falls leer).
Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung"
ausgewählt ist.
- Keine neuen Status eingeführt: `cancellationSentDate` vs.
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
- Erste 2 Runden zusätzlich ausführlich in
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
- Highlights:
- Runde 13: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
Privacy-Policy-XSS
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
Self-Grant + Existence-Disclosure
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
neuen Critical-Findings → diminishing returns erreicht
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
- Deployment-Checkliste komplett (in HARDENING.md)
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
- Email-Reset-Token mit 2h Gültigkeit (kryptografisch sicher: 32 Byte Random)
- Funktioniert für Mitarbeiter UND Portal-Kunden (Typ-Auswahl)
- User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert
- Reset-Link per Email mit schönem HTML-Template
- Nach Reset: alle bestehenden Sessions werden gekickt
- **Rate-Limiting** gegen Brute-Force
- Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zählen nicht)
- Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP
- **Cron-Job für automatische Geburtstagsgrüße**
- Täglich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Email-Versand über System-E-Mail, Du/Sie-abhängiger Text
- Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war)
- Marker lastBirthdayGreetingYear verhindert Doppel-Versand
- [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)
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# Factory-Defaults-Export holt eine ZIP vom laufenden OpenCRM und legt sie
# in ./factory-exports/ ab. Dieselbe ZIP, die du auch über die UI bekommst.
#
# Workflow:
# ./factory-export.sh # default: localhost:3010, admin@admin.com
# OPENCRM_URL=https://crm.example.de \
# OPENCRM_EMAIL=admin@example.de \
# ./factory-export.sh # gegen die Prod-Instanz
#
# Optional:
# OPENCRM_PASSWORD=… (sonst wird interaktiv abgefragt)
#
# Die ZIP ist gitignored du kannst sie via scp transferieren und mit
# ./factory-import.sh auf der anderen Seite einspielen.
set -euo pipefail
URL="${OPENCRM_URL:-http://localhost:3010}"
EMAIL="${OPENCRM_EMAIL:-admin@admin.com}"
PASSWORD="${OPENCRM_PASSWORD:-}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXPORT_DIR="$REPO_ROOT/factory-exports"
mkdir -p "$EXPORT_DIR"
if [ -z "$PASSWORD" ]; then
read -r -s -p "Passwort für $EMAIL @ $URL: " PASSWORD
echo
fi
echo "→ Login als $EMAIL @ $URL"
LOGIN_RESPONSE="$(curl -sS -X POST "$URL/api/auth/login" \
-H 'Content-Type: application/json' \
--data-raw "$(E="$EMAIL" P="$PASSWORD" python3 -c 'import json,os;print(json.dumps({"email":os.environ["E"],"password":os.environ["P"]}))')")"
TOKEN="$(printf '%s' "$LOGIN_RESPONSE" | python3 -c 'import json,sys;d=json.load(sys.stdin);print((d.get("data") or {}).get("token","") or d.get("token",""))')"
if [ -z "$TOKEN" ]; then
echo "✗ Login fehlgeschlagen. Antwort:"
echo "$LOGIN_RESPONSE" | head -c 500
echo
exit 1
fi
TIMESTAMP="$(date +%Y-%m-%d-%H%M)"
DEST="$EXPORT_DIR/factory-defaults-$TIMESTAMP.zip"
echo "→ Lade ZIP nach $DEST"
HTTP_CODE="$(curl -sS -o "$DEST" -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" \
"$URL/api/factory-defaults/export")"
if [ "$HTTP_CODE" != "200" ]; then
echo "✗ Export-Endpoint antwortete mit HTTP $HTTP_CODE"
rm -f "$DEST"
exit 1
fi
SIZE_KB="$(du -k "$DEST" | cut -f1)"
echo "✓ Export erfolgreich: $DEST (${SIZE_KB} KB)"
echo
echo "Inhalt:"
unzip -l "$DEST" | sed 's/^/ /'
View File

Some files were not shown because too many files have changed in this diff Show More