Files
opencrm/README.md
T
duffyduckandClaude Opus 5 ffeb4dbfd8 Caddy per Schalter in der .env statt per CLI-Flag
Frage aus dem Betrieb: laesst sich ein Compose-Abschnitt ueber eine Variable
in der .env zu- und abschalten, statt ihn auszukommentieren? Bedingte Bloecke
kennt Compose nicht - aber COMPOSE_PROFILES darf in der .env stehen und
aktiviert das Profil, ohne dass ein Flag noetig ist.

  COMPOSE_PROFILES=        -> Caddy wird nicht angelegt (Standard)
  COMPOSE_PROFILES=caddy   -> Caddy startet bei `docker-compose up -d` mit

Vorteil gegenueber `--profile caddy`: der Schalter wirkt auch bei down, logs
und ps, wo das Flag leicht vergessen wird und dann verwaiste Container
zurueckbleiben.

Empirisch geprueft mit einem separaten Testprojekt (compose 1.29.2): ohne den
Eintrag startete nur der ungeschuetzte Dienst, mit Eintrag beide.

Dokumentiert in .env.example, im Kommentar am caddy-Dienst und in der README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:43:59 +02:00

1785 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenCRM
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
- **Adressen**: Mehrere Liefer-/Melde- und Rechnungsadressen pro Kunde
- **Bankkarten**: Mit Ablaufdatum, Aktiv-Status und Dokument-Upload (PDF)
- **Ausweise**: Personalausweis, Reisepass, etc. mit Ablaufdatum und Dokument-Upload (PDF)
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie,
Pflicht-Zuordnung zur Lieferadresse und Folgezähler-Kette
(Auto-Propagation auf alle Verträge mit dem Vorgänger)
- **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)
- KFZ-Versicherung
- **Folgeverträge**: Automatische Datenübernahme bei Anbieterwechsel
- **Vertriebsplattformen**: Verwaltbar über WebUI
- **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)
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
## Tech Stack
- **Frontend**: React 18, TypeScript, Tailwind CSS, React Query
- **Backend**: Node.js, Express 4.x, TypeScript
- **Datenbank**: MariaDB
- **ORM**: Prisma
- **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
> - Express 4.x → `@types/express@^4.17.x`
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
## Quick-Start mit Docker (empfohlen)
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Werte anpassen, Secrets rotieren!
docker compose up -d
```
Browser:
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` Initial-Passwort siehe unten)
- **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:** Bei leerer DB läuft der Seed automatisch
> (kein Eingriff nötig). Nur wenn du eine bereits befüllte DB erneut
> seeden willst, einmalig `RUN_SEED=true` in der `.env` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`.
>
> Der initiale Admin-User ist **`admin@admin.com`**. Das Passwort wird
> beim Seed zufällig generiert (28 Zeichen) und genau einmal in den
> Container-Logs ausgegeben:
> ```bash
> docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
> ```
> Alternativ kannst du **vor dem ersten Start** ein eigenes Passwort
> setzen, indem du `SEED_ADMIN_PASSWORD=…` (mindestens 25 Zeichen, sonst
> wird's ignoriert) in der `.env` einträgst. Nach dem ersten Login
> sollte das Passwort über *Einstellungen → Mein Profil* geändert
> werden.
## Voraussetzungen
- Docker & Docker Compose v2
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
## Installation für Entwicklung (ohne Container)
### 1. Repository klonen
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Konfiguration anpassen
```
### 2. MariaDB-Container starten
```bash
docker compose up -d db
```
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
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
### 3. Backend einrichten
```bash
cd backend
# Dependencies installieren
npm install
# .env-Datei erstellen (falls noch nicht vorhanden)
cp .env.example .env
```
Die `.env`-Datei sollte folgende Werte enthalten:
```env
# Database
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT Access-/Refresh-Token-Pattern (SPA-Standard)
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
# Refresh-Token (httpOnly-Cookie, lang)
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# Server
PORT=3001
NODE_ENV=development
```
### 4. Datenbank initialisieren
```bash
# Prisma Client generieren und Migrationen ausführen
npx prisma migrate dev
# Seed-Daten einspielen (Admin-User, Rollen, Berechtigungen)
npm run db:seed
```
### 5. Frontend einrichten
```bash
cd ../frontend
# Dependencies installieren
npm install
```
## Anwendung starten
### Backend starten (Terminal 1)
```bash
cd backend
npm run dev
```
Das Backend läuft auf `http://localhost:3001`
### Frontend starten (Terminal 2)
```bash
cd frontend
npm run dev
```
Das Frontend läuft auf `http://localhost:5173`
## Erster Login
Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** `admin@admin.com`
- **Passwort:** wird beim Seed zufällig generiert (28 Zeichen) und einmal
in den Container-Logs ausgegeben:
```bash
docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
```
Beispiel-Ausgabe:
```
========================================================
Admin-User: admin@admin.com
Initial-Passwort: <28-zeichen-zufalls-passwort>
⚠️ Dieses Passwort wird hier EINMAL ausgegeben!
Bitte sofort nach dem ersten Login ändern.
========================================================
```
Wer das Log nicht mehr findet (z.B. Container schon länger gerollt),
setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` in der `.env` und startet
einmalig mit `RUN_SEED=true` neu der Seed ist idempotent und
überschreibt das Admin-Passwort. Danach `RUN_SEED` wieder auf `false`.
> **Tipp:** Wer von Anfang an ein eigenes Admin-Passwort haben möchte,
> setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` **bevor** der erste
> `docker compose up -d` läuft. Der Wert wird dann statt des Zufalls-
> passworts verwendet (siehe `.env.example`).
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren
> siehe [Production-Deployment](#production-deployment).
### Zugangsdaten auf bestehender DB neu setzen (Reseed)
Nützlich z.B. auf **Staging/Test nach einem DB-Reset**, wenn Admin-/Portal-
Passwörter randomisiert sind und man wieder **bekannte** Zugangsdaten braucht
(etwa für einen Pentester).
**Der Seed ist datenerhaltend:** Er `upsert`et nur die Rollen und den
Admin-User (`admin@admin.com`) und synchronisiert die Rollen-Berechtigungen.
**Kundendaten, Verträge, Bankkarten usw. werden NICHT angefasst** (das einzige
`deleteMany` betrifft ausschließlich Rollen↔Permission-Zuordnungen). Ein
erneuter Seed setzt also faktisch nur das Admin-Passwort neu.
**Admin-Passwort deterministisch neu setzen:**
```bash
# 1) In der .env / docker-compose environment setzen:
SEED_ADMIN_PASSWORD=<ein dir bekanntes Passwort, MIND. 25 Zeichen>
RUN_SEED=true # erzwingt den Seed auch bei nicht-leerer DB
# 2) Container neu starten der Entrypoint seedet erneut (upsert):
docker compose up -d
# 3) Login: admin@admin.com / <SEED_ADMIN_PASSWORD>
# 4) Danach RUN_SEED wieder auf false (sonst re-seedet jeder Restart harmlos,
# aber unnötig). SEED_ADMIN_PASSWORD kann gesetzt bleiben.
```
> ⚠️ `SEED_ADMIN_PASSWORD` **muss ≥ 25 Zeichen** sein, sonst wird es ignoriert
> (Passwort-Policy) und der Seed generiert wieder ein Zufallspasswort (→ Logs,
> siehe oben).
**Portal-Login (Kunde):** Portal-Passwörter werden **nicht** vom Seed gesetzt.
Nach dem Admin-Login lässt sich in der Kundenakte für einen (Test-)Kunden das
Portal-Passwort im UI **anzeigen/neu setzen** damit hat man wieder einen
bekannten Portal-Zugang für Scoping-Tests.
## 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
# Siegel fuer das Audit-Log schuetzt die Beweisspur vor nachtraeglicher
# Faelschung. Siehe Abschnitt "Audit-Siegel" weiter unten.
AUDIT_HMAC_KEY=$(openssl rand -hex 32)
```
### Deployment-Modus: On-Prem vs. Cloud
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
SSRF-Schalter:
```env
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
SSRF_BLOCK_PRIVATE_IPS=true
```
### Audit-Siegel (`AUDIT_HMAC_KEY`)
**Worum geht es?**
OpenCRM führt ein Audit-Log: eine lückenlose Aufzeichnung, wer wann welche
Daten gesehen oder geändert hat. Das ist die Beweisspur, wenn es Streit gibt,
etwas verschwindet oder der Datenschutz nachfragt.
Damit diese Aufzeichnung etwas wert ist, muss man ihr ansehen können, ob
jemand nachträglich daran herumgeschrieben hat. Dafür trägt jeder Eintrag
einen Fingerabdruck, der auch den Fingerabdruck des vorherigen Eintrags
enthält wie eine Kette. Ändert jemand einen alten Eintrag, passen die
Fingerabdrücke nicht mehr und es fällt auf.
**Und wozu dann noch ein Schlüssel?**
Weil jemand mit Zugriff auf die Datenbank die ganze Kette neu berechnen
könnte. Er ändert also einen Eintrag zum Beispiel einen fehlgeschlagenen
Login-Versuch in einen erfolgreichen und zieht anschließend alle
Fingerabdrücke glatt. Danach sieht die Fälschung echt aus.
Mit `AUDIT_HMAC_KEY` bekommt jeder Eintrag zusätzlich ein **Siegel**, das sich
nur mit diesem Schlüssel erzeugen lässt. Der Schlüssel liegt in der
`.env`-Datei, also **außerhalb der Datenbank**. Wer nur die Datenbank in die
Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
> Bildlich: Die Fingerabdruck-Kette ist die fortlaufende Nummerierung der
> Aktenseiten. Der Schlüssel ist der Stempel des Notars. Die Akte steht zwar
> im Schrank aber den Stempel hat nur der Notar.
**Einrichten**
```bash
# Einmalig pro Umgebung einen Schlüssel erzeugen
openssl rand -hex 32
```
**In welche Datei?** Das hängt davon ab, wie du OpenCRM betreibst:
| Betrieb | Datei |
|---|---|
| **Docker** (Standard) | **`.env` im Projektstamm** |
| Backend direkt, ohne Container (Entwicklung) | `backend/.env` |
Beim Docker-Betrieb reicht der Eintrag in der Stamm-`.env` `docker-compose.yml`
reicht die Variable an den Container weiter. Ein Eintrag in `backend/.env` wirkt
dort **nicht**, weil der Container diese Datei nicht liest.
Wichtig: **pro Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion)
und niemals ins Git-Repository.
**Bestehende Installation: bisher kein Schlüssel gesetzt?**
Genau so ist es gedacht der Schlüssel kam mit einem Update dazu. Du kannst
ihn jederzeit nachträglich setzen:
1. Schlüssel erzeugen (`openssl rand -hex 32`), in die `.env` eintragen
2. Backend neu starten
3. Fertig mehr ist nicht nötig
Was dabei mit deinen **bereits vorhandenen** Einträgen passiert:
- Sie bleiben **unverändert** und weiterhin gültig. Es wird nichts neu
berechnet und nichts nachträglich gesiegelt.
- Ab dem Neustart werden **neue** Einträge gesiegelt. Alt und neu liegen
friedlich nebeneinander, die Prüfung erkennt für jeden Eintrag automatisch,
nach welchem Verfahren er zu prüfen ist.
- Es gibt **keine** Fehlalarme dadurch. (Nachgemessen auf einer Installation
mit 4903 alten, 57 mittleren und 67 gesiegelten Einträgen: 0 Beanstandungen.)
Rückwirkend lässt sich der Schutz nicht herstellen ältere Einträge bleiben
ungesiegelt. Das ist kein Fehler, sondern liegt in der Natur der Sache: Ein
Siegel kann man nur beim Anlegen vergeben, nicht nachträglich. Je früher du
den Schlüssel setzt, desto größer der geschützte Zeitraum.
> ⚠️ **Einmal gesetzt, nicht wieder abschalten.** Fehlt der Schlüssel später
> (z. B. weil er beim Container-Rebuild aus der `.env` verlorenging), läuft die
> Anwendung normal weiter aber die in dieser Zeit entstandenen Einträge sind
> ungesiegelt und werden von der Prüfung **beanstandet**, sobald der Schlüssel
> wieder da ist. Das ist Absicht: Ein ungesiegelter Eintrag inmitten
> gesiegelter ist von einer Fälschung nicht zu unterscheiden. Nimm den
> Schlüssel deshalb in deine Deploy-Checkliste auf.
Ein noch nicht ersetzter Platzhalter (`<hier-eigenen-wert-eintragen>`) zählt
bewusst **nicht** als Schlüssel sonst würde mit einem öffentlich bekannten
Wert gesiegelt. Das Backend schreibt in diesem Fall eine Warnung ins Log und
lässt das Siegel aus.
**Häufige Fragen**
| Frage | Antwort |
|---|---|
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
| Was, wenn ich ihn verliere? | Lässt du das Feld dann **leer**, gelten die gesiegelten Einträge als **„nicht prüfbar"** ausdrücklich nicht als gefälscht, also kein Fehlalarm. Der Nachweis für diesen Zeitraum ist aber weg. Deshalb: sichern wie ein Passwort. |
| Und wenn ich stattdessen einen **neuen** Schlüssel eintrage? | Dann werden die alten Einträge als **„manipuliert" gemeldet** das System kann einen falschen Schlüssel nicht von einer echten Fälschung unterscheiden. Trage den alten Schlüssel deshalb zusätzlich unter `AUDIT_HMAC_KEY_OLD` ein (siehe unten). |
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
| Verlangsamt das etwas? | Nein, spürbar nicht. |
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in
falsche Hände geraten ist geht das ohne Datenverlust:
```env
AUDIT_HMAC_KEY=<neuer Schlüssel>
AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
```
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar.
> ⚠️ **`AUDIT_HMAC_KEY_OLD` nicht voreilig leeren.** Solange Einträge
> existieren, die mit einem alten Schlüssel gesiegelt wurden, muss dieser dort
> stehen bleiben. Entfernst du ihn, werden diese Einträge als **„manipuliert"
> gemeldet** nicht als „nicht prüfbar". Das System kann einen falschen
> Schlüssel nicht von einer echten Fälschung unterscheiden. Leeren kannst du
> das Feld gefahrlos erst, wenn die betroffenen Einträge durch die
> Aufbewahrungsfristen ohnehin gelöscht sind.
**Mehrfach gewechselt?** Das Feld nimmt mehrere Schlüssel kommagetrennt auf
vom jüngsten zum ältesten:
```env
AUDIT_HMAC_KEY=<aktueller Schlüssel>
AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
```
Ohne das würde beim zweiten Wechsel der zuerst genutzte Schlüssel verloren
gehen und alle damit gesiegelten Einträge fälschlich als manipuliert
erscheinen.
**Prüfen, ob alles in Ordnung ist**
Die Prüfung läuft derzeit nur über die API (eine Schaltfläche in der Oberfläche
gibt es dafür noch nicht) als angemeldeter Benutzer mit dem Recht
`audit:read`:
```bash
curl -X POST https://crm.deine-domain.de/api/audit-logs/verify \
-H "Authorization: Bearer <Access-Token>"
```
Die Antwort enthält einen Klartext-Satz im Feld `message` und unterscheidet
zwei Dinge:
- **„Manipulierte Einträge"** jemand hat einen bestehenden Eintrag
nachträglich verändert. Das ist ernst.
- **„Strukturelle Lücken"** die Kette hat eine Unterbrechung, die Inhalte
sind aber unverändert. Meist harmlos (z. B. gelöschte alte Einträge).
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
in Ordnung. Steigt sie, lohnt ein Blick.
<details>
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
**Verfahren.** Jeder Audit-Eintrag trägt einen Hash über seine Inhaltsspalten
plus den Hash des Vorgängers (`previousHash`) daraus entsteht die Kette. Es
gibt drei Hash-Versionen, die Spalte `hashVersion` hält fest, welche verwendet
wurde:
| Version | Verfahren | Abdeckung |
|---|---|---|
| 1 | SHA-256 | 7 Felder Altbestand vor der Härtung |
| 2 | SHA-256 | alle 24 Inhaltsspalten, unsigniert |
| 3 | **HMAC-SHA256** mit `AUDIT_HMAC_KEY` | alle 24 Inhaltsspalten, signiert |
Bestandsdaten werden **nicht** nachträglich neu berechnet ein Rehash würde die
Beweiskraft der Vergangenheit überschreiben. Alte Einträge bleiben mit ihrer
Version gültig.
**Version-Floor.** Die zu erwartende Version wird aus der Kette abgeleitet
(erste je mit Version *n* geschriebene Zeile), **nicht** aus der Selbstauskunft
der Zeile. Sonst ließe sich per Downgrade (`hashVersion` 3 → 1) die schwächere
Prüfung erzwingen und anschließend ein gültiger Hash über die wenigen
abgedeckten Felder nachziehen. Zusätzlich gilt: Eine unerklärte Lücke direkt vor
einer signierten Zeile ist ein Befund, kein struktureller Zufall deren
`previousHash` lässt sich ohne Schlüssel nicht fälschen.
**Löschungen.** `POST /api/audit-logs/cleanup` schreibt ein Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag. Die
Prüfung liest es aus und meldet nur Lücken **ohne** dokumentierte Löschung als
erklärungsbedürftig (`unexplainedGaps`) sonst könnte sich eine böswillige
Löschung als harmlose Lücke tarnen.
**Rückgabe von `POST /api/audit-logs/verify`:**
| Feld | Bedeutung |
|---|---|
| `tamperedEntries` | Inhalt nachträglich verändert, Versions-Downgrade oder gebrochener Anker **ernst** |
| `chainGaps` | Verkettung unterbrochen (alle) |
| `unexplainedGaps` | Teilmenge davon ohne dokumentierte Löschung |
| `unverifiableEntries` | signiert, aber kein Schlüssel konfiguriert **kein** Manipulationsverdacht |
**Fail-safe.** Ohne `AUDIT_HMAC_KEY` schreibt der Dienst weiter Version 2; das
Logging fällt nie wegen fehlender Konfiguration aus.
**Bekannte Grenze.** Der Anker schützt gegen reinen Datenbank-Schreibzugriff.
Wer Schlüssel *und* Datenbank kontrolliert, kann die Kette konsistent neu
rechnen. Eine Off-Site-Notarisierung (regelmäßiger Export/Versiegelung des
Kettenkopfes außerhalb des Systems) wäre die nächste Stufe.
**Achtung zwei Endpunkte mit Nebenwirkung:**
`POST /api/audit-logs/rehash` berechnet alle Hashes neu; danach meldet die
Prüfung überall „gültig", aber eine bestehende Fälschung würde mitbesiegelt.
`POST /api/audit-logs/cleanup` löscht nach Aufbewahrungsregeln, jede gelöschte
Zeile erzeugt eine Lücke. Beide laufen nur manuell und schreiben einen Marker
ins Log.
</details>
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` wenn Frontend
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
auf derselben Domain via Proxy-Path.
- **Admin-Initial-Passwort ändern**: das beim Seed generierte Zufalls-Passwort
(siehe [Erster Login](#erster-login)) im Profil überschreiben oder gleich
per `SEED_ADMIN_PASSWORD` in der `.env` vor dem ersten Start setzen.
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
hinterlegen, Test-Alert senden, Digest aktivieren.
- Vollständige Hardening-Story + restliche Trade-offs:
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
### ⚠️ Wichtig: gzip für `/api/*` am Reverse-Proxy deaktivieren (BREACH-Schutz)
Wenn ein TLS-Reverse-Proxy (Nginx Proxy Manager, Caddy, eigener Nginx, …) HTTPS
terminiert und Antworten gzip-komprimiert, ist die **BREACH-Attacke** (CVE-2013-3587)
theoretisch möglich: aus der gzip-komprimierten Response-Größe könnten unter
ungünstigen Umständen Secrets erraten werden. Auch wenn unsere JWT-basierte SPA
das Risiko praktisch klein hält (keine reflektierten Secrets im Response-Body),
geht ein Penetration-Test mit testssl trotzdem auf „medium Ausnutzbar: Ja".
**Lösung:** gzip-Komprimierung nur für statische Frontend-Assets erlauben, für
`/api/*` deaktivieren. Statische Bundles bleiben damit performant ausgeliefert,
JSON-API-Responses werden ohne Kompression gesendet → BREACH ist dort kein
Einfallstor mehr.
**Nginx Proxy Manager (NPM):**
1. Proxy-Hosts → den CRM-Host → **Edit**
2. Tab **Custom Locations** → **„Add location"**
3. **Define location:** `/api/`
4. **Scheme:** `http`, **Forward Hostname/IP:** wie im Haupt-Host
(z.B. `172.0.2.39`), **Forward Port:** `3010`
5. Zahnrad rechts an der Location → erweiterte Config eintragen:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
6. **Save** (Location), **Save** (Proxy-Host)
> Der `more_clear_headers`-Befehl kommt aus dem `headers-more`-Modul, das
> bei NPM standardmäßig dabei ist. Damit verschwinden die Banner
> `Server: openresty` und `x-served-by: …` aus den Responses Pentest-
> Tools können den eingesetzten Webserver nicht mehr direkt aus dem Header
> ablesen. Wer das auch auf der Hauptlocation will, kann denselben Eintrag
> zusätzlich im **Advanced**-Tab des Proxy-Hosts setzen.
**Plain Nginx** (falls eigener Nginx statt NPM):
```nginx
location /api/ {
gzip off;
proxy_pass http://backend:3010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By; # braucht headers-more-Modul
}
# Optional global im server { … }-Block:
server_tokens off;
```
**Verifikation:**
```bash
# 1) gzip ist für /api/ deaktiviert (sollte leer sein)
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/api/health \
| grep -i content-encoding
# 2) Server-/x-served-by-Banner sind weg (sollte leer sein)
curl -sI https://kundencenter.deine-domain.de/api/health \
| grep -iE '^(server|x-served-by):'
```
#### Was mit gzip auf `/` (SPA-HTML) ist
Pentest-Tools wie `testssl` melden BREACH **trotzdem weiter** für die
Root-URL `/`, weil die SPA-`index.html` bewusst weiter gzip-komprimiert
ausgeliefert wird (Performance: 50 KB → ~10 KB). Bei OpenCRM ist der
Angriff dort nicht ausnutzbar:
- Die `/`-Response ist die statische `index.html` aus dem Vite-Build
- Sie reflektiert **keinen user-controlled Input**
- Sie enthält **keine Secrets** (JWT-Access ist im `Authorization`-Header,
Refresh-Token im httpOnly-Cookie beides nicht im HTML-Body)
Ohne Secret-im-Body und ohne Input-Reflektion hat BREACH keinen Hebel.
##### Wer den Audit-Marker trotzdem weg haben will
Wichtig: nicht einfach eine Custom-Location für `/` mit `gzip off`
anlegen das wäre ein **prefix-Match** und würde **alle** Pfade
außer `/api/*` betreffen, also auch `/assets/*.{js,css}`. Das JS-Bundle
käme dann unkomprimiert (~500 KB statt ~150 KB) → spürbarer
Performance-Verlust für nichts.
Sauber ist eine **exact-Match-Location** (`location = /`) die fängt
nur die Root-URL ohne weitere Pfad-Komponente:
**Variante A** Custom Location im NPM-UI (falls `= /` im
„Define location"-Feld akzeptiert wird):
| Feld | Wert |
|---|---|
| Define location | `= /` |
| Scheme | `http` |
| Forward Hostname/IP | wie im Haupt-Host |
| Forward Port | `3010` |
Im Zahnrad-Edit der Location:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
**Variante B** wenn das NPM-UI das `=` nicht akzeptiert, dieselbe
Logik im **Advanced**-Tab des Proxy-Hosts:
```nginx
location = / {
gzip off;
proxy_pass $forward_scheme://$server:$port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By;
}
```
Verifikation `/` ohne gzip, `/assets/*` aber weiter mit:
```bash
# Root: kein Content-Encoding mehr
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/ \
| grep -i content-encoding
# /assets/<file>.js: weiterhin gzip (Performance bleibt erhalten)
JS=$(curl -s https://kundencenter.deine-domain.de/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
curl -sI -H 'Accept-Encoding: gzip' "https://kundencenter.deine-domain.de/$JS" \
| grep -i content-encoding
```
Kostet 40 KB extra pro Tab-Reload aber dafür ist auch der letzte
BREACH-Marker weg und Pentest-Reports landen auf 0×MEDIUM.
## Developer-Tools aktivieren
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
1. Einmalig im Browser-Console ausführen:
```javascript
fetch('/api/developer/setup', { method: 'POST' })
```
2. Ausloggen und neu einloggen
Alternativ können Developer-Rechte pro Benutzer vergeben werden:
- Benutzer bearbeiten > "Entwicklerzugriff" aktivieren
## Email-Provisionierung
Das System unterstützt die automatische Erstellung von E-Mail-Weiterleitungen auf Hosting-Servern für Stressfrei-Wechseln Adressen.
### Unterstützte Provider
- **Plesk** (implementiert)
- **cPanel** (vorbereitet)
- **DirectAdmin** (vorbereitet)
### Konfiguration
1. **Einstellungen** → **Email-Provisionierung** öffnen
2. Neuen Provider hinzufügen:
- **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
- **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:
- Ein aktiver Standard-Provider konfiguriert ist
- Der Kunde eine E-Mail-Adresse hat
Bei aktivierter Checkbox wird automatisch:
1. Geprüft, ob die E-Mail-Adresse bereits existiert
2. Falls nicht: E-Mail-Adresse beim Provider angelegt mit Weiterleitung an:
- Kunden-E-Mail-Adresse
- Standard-Weiterleitungsadresse (falls konfiguriert)
## Befehle
### Backend
```bash
npm run dev # Entwicklungsserver starten
npm run build # Produktions-Build erstellen
npm run db:studio # Prisma Studio (Datenbank-GUI)
npm run db:migrate # Neue Migration erstellen
npm run db:seed # Seed erneut ausführen
```
### Frontend
```bash
npm run dev # Entwicklungsserver starten
npm run build # Produktions-Build erstellen
npm run preview # Build-Vorschau
```
### Docker (Entwicklung)
```bash
docker-compose up -d # Container starten
docker-compose down # Container stoppen
docker-compose down -v # Container stoppen + Daten löschen
docker-compose logs -f # Logs anzeigen
```
### Betrieb mit eigenem SSL (optionaler Caddy-Dienst)
Es gibt **eine** `docker-compose.yml` und **eine** `.env` im Projektstamm.
Standardmässig bringt der Stack **keinen** Reverse-Proxy mit die Annahme ist,
dass du bereits einen hast (nginx, Nginx Proxy Manager, Traefik) und OpenCRM
dahinter hängst.
Hast du **keinen**, kannst du Caddy als optionalen Dienst dazuschalten. Er holt
und erneuert das SSL-Zertifikat automatisch:
Dafür gibt es einen Schalter in der `.env` nichts muss auskommentiert werden:
```env
COMPOSE_PROFILES=caddy # leer = kein Caddy (Standard)
DOMAIN=crm.example.de
HTTPS_ENABLED=true
```
```bash
docker-compose up -d # startet jetzt zusätzlich Caddy
```
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
angelegt** der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
bei Folgebefehlen leicht vergisst.
**Was dabei zu beachten ist:**
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
die Mengenbegrenzung von Let's Encrypt.
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
Zertifikatsausstellung fehl.
- `HTTPS_ENABLED=true` setzen das aktiviert das `Secure`-Attribut am
Refresh-Cookie und die korrekte Proxy-Erkennung.
- Der direkte App-Port (`OPENCRM_PORT`, Standard 3010) bleibt weiterhin offen.
Wenn nur Caddy von aussen erreichbar sein soll, schliesse ihn per Firewall
oder setze ihn in der `.env` auf einen Wert, den du nicht freigibst.
Die Konfiguration liegt in der `Caddyfile` im Projektstamm. Sie liefert
Sicherheits-Header mit und komprimiert bewusst **nicht** die API-Antworten
(BREACH-Schutz, siehe Abschnitt zum Reverse-Proxy oben).
## Projektstruktur
```
opencrm/
├── backend/
│ ├── src/
│ │ ├── controllers/ # Request-Handler
│ │ │ ├── auditLog.controller.ts # Audit-Log API
│ │ │ └── gdpr.controller.ts # DSGVO API
│ │ ├── middleware/ # Auth, Validation
│ │ │ ├── audit.ts # Automatisches API-Logging
│ │ │ └── auditContext.ts # Before/After Context
│ │ ├── routes/ # API-Endpunkte
│ │ │ ├── auditLog.routes.ts # Audit-Log Routes
│ │ │ └── gdpr.routes.ts # DSGVO Routes
│ │ ├── services/ # Business-Logik
│ │ │ ├── audit.service.ts # Hash-Kette, Logging
│ │ │ ├── consent.service.ts # Einwilligungen
│ │ │ └── gdpr.service.ts # Export, Löschung
│ │ ├── lib/
│ │ │ └── prisma.ts # Prisma mit Audit-Middleware
│ │ ├── types/ # TypeScript-Typen
│ │ └── index.ts # Server-Einstiegspunkt
│ ├── prisma/
│ │ ├── schema.prisma # Datenbank-Schema
│ │ └── seed.ts # Seed-Daten
│ ├── uploads/ # Hochgeladene Dokumente
│ │ ├── bank-cards/ # Bankkarten-Dokumente
│ │ ├── documents/ # Ausweis-Scans
│ │ ├── invoices/ # Rechnungsdokumente (Strom/Gas)
│ │ ├── gdpr/ # DSGVO-Löschnachweise
│ │ ├── business-registrations/ # Gewerbeanmeldungen
│ │ ├── commercial-registers/ # Handelsregisterauszüge
│ │ ├── privacy-policies/ # Datenschutzerklärungen
│ │ ├── cancellation-letters/ # Kündigungsschreiben
│ │ ├── cancellation-confirmations/ # Kündigungsbestätigungen
│ │ └── cancellation-*-options/ # Kündigungsdokumente Optionen
│ └── package.json
├── frontend/
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── pages/ # Seiten
│ │ │ └── settings/
│ │ │ ├── AuditLogs.tsx # Audit-Protokoll
│ │ │ └── GDPRDashboard.tsx # DSGVO-Dashboard
│ │ ├── hooks/ # Custom Hooks
│ │ ├── services/ # API-Client
│ │ ├── types/ # TypeScript-Typen
│ │ └── App.tsx # Haupt-Komponente
│ └── package.json
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
│ ├── Dockerfile # Multi-Stage Build
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
│ ├── Caddyfile # Reverse-Proxy mit SSL
│ └── entrypoint.sh # Container-Startup
├── docker-compose.yml # MariaDB-Container (Entwicklung)
└── README.md
```
## Berechtigungen
| Rolle | Kunden | Verträge | Benutzer | Plattformen | Audit/DSGVO | Developer |
|-------|--------|----------|----------|-------------|-------------|-----------|
| Admin | CRUD | CRUD | CRUD | CRUD | Vollzugriff | Optional |
| Mitarbeiter | CRUD | CRUD | - | Lesen | Lesen | - |
| Mitarbeiter (Lesen) | Lesen | Lesen | - | Lesen | - | - |
| Kunde | Eigene | Eigene | - | - | - | - |
## Troubleshooting
### Datenbank-Verbindungsfehler
1. Prüfe ob Container läuft: `docker ps`
2. Prüfe die DATABASE_URL in `.env`
3. Warte nach Container-Start ca. 10 Sekunden
### Prisma-Fehler
```bash
# Prisma Client neu generieren
npx prisma generate
# Schema zur Datenbank pushen (ohne Migration)
npx prisma db push
```
### Port bereits belegt
- Backend: `PORT` in `.env` ändern
- Frontend: In `vite.config.ts` anpassen
### Developer-Menü fehlt
```bash
# In der Browser-Console:
fetch('/api/developer/setup', { method: 'POST' }).then(r => r.json()).then(console.log)
# Danach ausloggen und neu einloggen
```
## Vertragstypen
### Standard-Vertragstypen
Folgende Vertragstypen werden bei Installation/Factory-Reset automatisch angelegt:
| Code | Name | Icon | Farbe |
|------|------|------|-------|
| ELECTRICITY | Strom | Zap | #FFC107 |
| GAS | Gas | Flame | #FF5722 |
| DSL | DSL | Wifi | #2196F3 |
| FIBER | Glasfaser | Cable | #9C27B0 |
| CABLE | Kabel Internet (Coax) | Cable | #00BCD4 |
| MOBILE | Mobilfunk | Smartphone | #4CAF50 |
| TV | TV | Tv | #E91E63 |
| CAR_INSURANCE | KFZ-Versicherung | Car | #607D8B |
> **Hinweis:** Vertragstypen können nur von Benutzern mit **Entwicklerzugriff** geändert werden, da Änderungen auch Anpassungen an den Formularen erfordern.
### Vertragstyp-spezifische Felder
Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
#### Strom & Gas (ELECTRICITY, GAS)
- Zähler-Auswahl (gefiltert auf die Lieferadresse des Vertrags)
- Jahresverbrauch (kWh/m³) bei Folgeverträgen mit Schätzwert
aus dem Vorvertrag und 1-Klick-Übernahme
- Grundpreis, Arbeitspreis (HT/NT bei Zweitarif)
- Sofort-Bonus, Neukunden-Bonus
- Vorversorger, Kundennummer beim Vorversorger
- Folgezähler-Workflow (Zählerwechsel): Wechseldatum + optionaler
Endstand des alten Zählers, der automatisch als Zählerstand
erfasst wird und in die Verbrauchsberechnung einfließt
#### Internet (DSL, CABLE, FIBER)
- Download/Upload (Mbit/s)
- Router Modell, Seriennummer
- Installationsdatum
- Benutzername, Passwort
- Rufnummern mit SIP-Zugangsdaten
| Vertragstyp | Zusatzfeld |
|-------------|------------|
| **Glasfaser (FIBER)** | Home-ID |
#### Mobilfunk (MOBILE)
- Datenvolumen (GB)
- Inklusiv-Minuten, Inklusiv-SMS
- Gerät-Modell, IMEI
- Multisim-Checkbox
- SIM-Karten (dynamisch erweiterbar):
- Rufnummer, SIM-Kartennummer (ICCID)
- PIN, PUK (verschlüsselt)
- Multisim-Flag, Hauptkarte-Flag
- Kartennutzer (optional) tatsächlicher Nutzer bei Firmen-/
Familienverträgen, kann vom Vertragsinhaber abweichen
> **Hinweis Multisim:** Nicht buchbar bei Klarmobil, Congstar, Otelo. Benötigt Freenet oder vergleichbar.
#### TV
- Receiver Modell
- Smartcard-Nummer
- Paket/Angebot
#### KFZ-Versicherung (CAR_INSURANCE)
- Kennzeichen, HSN, TSN, FIN/VIN
- Fahrzeugtyp, Erstzulassung
- SF-Klasse (Schadenfreiheitsklasse)
- Versicherungsart (Haftpflicht/Teilkasko/Vollkasko)
- Selbstbeteiligungen (Teilkasko, Vollkasko)
- Versicherungsscheinnummer
- Vorversicherer
### Standard-Anbieter
Folgende Anbieter werden bei Installation/Factory-Reset automatisch angelegt:
| Anbieter | Portal-URL |
|----------|------------|
| Vodafone | https://www.vodafone.de/meinvodafone/account/login |
| Klarmobil | https://www.klarmobil.de/login |
| Otelo | https://www.otelo.de/mein-otelo/login |
| Congstar | https://www.congstar.de/login/ |
| Telekom | https://www.telekom.de/kundencenter/startseite |
| O2 | https://www.o2online.de/ecare/selfcare |
| 1&1 | https://control-center.1und1.de/ |
### Anbieter-spezifische Felder
Einige Felder werden nur bei bestimmten Anbietern angezeigt:
| Anbieter | Vertragstyp | Zusatzfeld |
|----------|-------------|------------|
| **Vodafone** | DSL, Kabel Internet | Aktivierungscode |
> **Hinweis Multisim:** Bei Klarmobil, Congstar und Otelo ist Multisim **nicht** buchbar. Dafür wird Freenet oder ein vergleichbarer Anbieter benötigt.
## Rechnungsverwaltung (Energieverträge)
Für Strom- und Gas-Verträge können Rechnungen verwaltet werden, um den Abrechnungsstatus zu tracken.
### Rechnungstypen
| Typ | Beschreibung |
|-----|--------------|
| **Zwischenrechnung** (INTERIM) | Reguläre Jahresrechnung während der Vertragslaufzeit |
| **Schlussrechnung** (FINAL) | Endabrechnung nach Vertragskündigung/Deaktivierung |
| **Nicht verfügbar** (NOT_AVAILABLE) | Rechnung ist nicht mehr zu bekommen (z.B. Anbieter existiert nicht mehr) |
### Funktionen
- **Rechnungen hinzufügen/bearbeiten/löschen** in der Vertragsdetailansicht
- **Dokument-Upload** (PDF) - Pflicht, außer bei Typ "Nicht verfügbar"
- **Statusanzeige**: Grünes Badge bei Schlussrechnung, Orange bei fehlender Schlussrechnung
- **E-Mail-Anhänge als Rechnung speichern**: Direkt aus dem E-Mail-Client
### E-Mail-Integration
Bei E-Mails, die einem Energievertrag zugeordnet sind:
1. Toggle zwischen "Als Dokument" und "Als Rechnung" im Speichern-Dialog
2. Rechnungsdatum und Typ auswählen
3. Anhang wird automatisch als Rechnungsdokument gespeichert
## Vertrags-Cockpit
Dashboard zur Überwachung offener Aufgaben und fehlender Dokumente.
### Kategorien
| Kategorie | Prüfungen |
|-----------|-----------|
| **Fehlende Dokumente** | Kündigungsschreiben, Kündigungsbestätigung (wenn Kündigung markiert) |
| **Fehlende Rechnungen** | Schluss-/Zwischenrechnungen für Energieverträge |
| **Ablaufende Dokumente** | Ausweise, Bankkarten (nächste 30 Tage) |
### Rechnungsprüfung für Energieverträge
#### Schlussrechnung (gekündigte/deaktivierte Verträge)
| Status | Prüfung |
|--------|---------|
| **CANCELLED** / **DEACTIVATED** | Schlussrechnung oder "Nicht verfügbar" erforderlich |
#### Zwischenrechnung (laufende Verträge)
| Bedingung | Warnung |
|-----------|---------|
| Vertrag > 12 Monate alt, keine Rechnung | "Zwischenrechnung fehlt" |
| Letzte Rechnung > 12 Monate her | "Zwischenrechnung überfällig" |
> **Hinweis Status-Logik:**
> - **EXPIRED** = Laufzeit abgelaufen, aber Vertrag läuft ohne Kündigung weiter → Zwischenrechnung prüfen
> - **CANCELLED** = Aktive Kündigung → Schlussrechnung prüfen
> - **DEACTIVATED** = Manuell beendet → Schlussrechnung prüfen
### Vertrag zurückstellen (Snooze)
Wenn ein Vertrag EXPIRED ist (Laufzeit vorbei, läuft aber weiter), erscheint er im Cockpit. Manchmal ist Bleiben günstiger als Wechseln - aber der Vertrag soll nicht dauerhaft im Cockpit erscheinen.
**Lösung:** Vertrag temporär zurückstellen mit Datum für erneute Prüfung.
#### Snooze aktivieren
Im Cockpit hat jeder Vertrag einen Snooze-Button (Glocke mit Uhr). Optionen:
- **+3 Monate**
- **+6 Monate** (Empfohlen)
- **+12 Monate**
- **Eigenes Datum** (Datepicker)
#### Unterdrückte Warnungen (bei aktivem Snooze)
| Warnung | Beschreibung |
|---------|--------------|
| Kündigungsfrist | Frist läuft ab |
| Vertrag läuft ab | Enddatum naht |
| Kündigungsschreiben fehlt | Bei markierter Kündigung |
| Kündigungsbestätigung fehlt | Bei markierter Kündigung |
#### NICHT unterdrückte Warnungen
Diese Warnungen werden auch bei aktivem Snooze angezeigt:
| Warnung | Beschreibung |
|---------|--------------|
| Schlussrechnung fehlt | Beendeter Vertrag ohne Endabrechnung |
| Zwischenrechnung fehlt/überfällig | Laufender Vertrag ohne Jahresrechnung |
| Zählerstand fehlt | Fehlende Zählerstände bei Energieverträgen |
| Zugangsdaten fehlen | Portal-Passwort, SIP-Daten, etc. |
#### Snooze aufheben
**Im Cockpit:** Bei Verträgen in der Kategorie "Erneute Prüfung fällig" erscheint ein "Snooze aufheben"-Button.
**In Vertragsdetails:** Bei zurückgestellten Verträgen erscheint ein bernsteinfarbenes Badge "Zurückgestellt bis [Datum]" mit X-Button zum Aufheben.
#### Erneute Prüfung fällig
Wenn das Snooze-Datum in der Vergangenheit liegt, erscheint der Vertrag in der neuen Kategorie "Erneute Prüfung fällig" mit Angabe, seit wie vielen Tagen die Prüfung fällig ist.
### Status-Info Modal
An verschiedenen Stellen (Vertragsformular, Vertragsdetails, Vertragsübersicht, Kundenansicht) zeigt ein -Icon neben dem Status eine Erklärung aller Vertragsstatus:
| Status | Bedeutung |
|--------|-----------|
| **Entwurf** | Vertrag wird noch vorbereitet |
| **Ausstehend** | Wartet auf Aktivierung |
| **Aktiv** | Vertrag läuft normal |
| **Abgelaufen** | Laufzeit vorbei, läuft aber ohne Kündigung weiter |
| **Gekündigt** | Aktive Kündigung eingereicht, Vertrag endet |
| **Deaktiviert** | Manuell beendet/archiviert |
## E-Mail-Client
Ein vollständig integrierter E-Mail-Client pro Kunde mit IMAP-Empfang und SMTP-Versand.
### Funktionen
#### E-Mails lesen & verwalten
- **E-Mail-Tab in Kundenansicht** mit Ordnern: Posteingang, Gesendet, Papierkorb
- **Mehrere E-Mail-Konten** pro Kunde (Dropdown zur Auswahl)
- **E-Mail-Detailansicht** mit HTML/Text-Body, Absender, Empfänger, CC, Datum
- **Gelesen/Ungelesen** markieren
- **Favoriten** (Stern) für wichtige E-Mails
- **Papierkorb** mit Wiederherstellen und endgültigem Löschen
#### E-Mails schreiben
- **Neue E-Mail verfassen** mit An, CC, Betreff, Text
- **Antworten** mit zitiertem Originaltext
- **Dateianhänge** (max. 10 MB pro Datei, 25 MB gesamt)
- **SMTP-Versand** über die gewählte StressfreiEmail-Adresse
#### Vertragszuordnung
- **E-Mails zu Verträgen zuordnen** für bessere Nachverfolgung
- **E-Mail-Tab in Vertragsansicht** zeigt nur zugeordnete E-Mails
- **Automatische Zuordnung** bei Versand aus Vertragskontext
- **Manuelle Zuordnung** über Suchfeld und Vertragsauswahl
#### Anhänge
- **Anhangsliste** in E-Mail-Detail
- **Download** einzelner Anhänge
- **Inline-Ansicht** (im Browser öffnen)
### Technische Details
#### Backend-Services
| Service | Beschreibung |
|---------|--------------|
| `imapService.ts` | IMAP-Client (ImapFlow) für E-Mail-Empfang |
| `smtpService.ts` | SMTP-Client (Nodemailer) für E-Mail-Versand |
| `cachedEmail.service.ts` | E-Mail-Caching, Synchronisation, Zuordnung |
#### API-Endpunkte
```
GET /api/customers/:id/emails # E-Mails für Kunde
GET /api/contracts/:id/emails # E-Mails für Vertrag
GET /api/emails/:id # Einzelne E-Mail mit Body
POST /api/stressfrei-emails/:id/sync # IMAP-Synchronisation
POST /api/stressfrei-emails/:id/send # E-Mail senden
POST /api/emails/:id/assign # Vertrag zuordnen
DELETE /api/emails/:id/assign # Zuordnung aufheben
PATCH /api/emails/:id/read # Gelesen/Ungelesen
POST /api/emails/:id/star # Favorit umschalten
DELETE /api/emails/:id # In Papierkorb
POST /api/emails/:id/restore # Aus Papierkorb wiederherstellen
DELETE /api/emails/:id/permanent # Endgültig löschen
GET /api/emails/:id/attachments/:filename # Anhang herunterladen
# Rechnungen (Invoices)
GET /api/invoices/ecd/:ecdId # Rechnungen für EnergyContractDetails
POST /api/invoices/ecd/:ecdId # Rechnung hinzufügen
PUT /api/invoices/ecd/:ecdId/:invoiceId # Rechnung bearbeiten
DELETE /api/invoices/ecd/:ecdId/:invoiceId # Rechnung löschen
POST /api/invoices/:invoiceId/upload # Rechnungsdokument hochladen
# Cockpit
GET /api/contracts/cockpit # Offene Aufgaben abrufen
```
#### Datenbank-Modell
```prisma
model CachedEmail {
id Int @id @default(autoincrement())
stressfreiEmailId Int
stressfreiEmail StressfreiEmail @relation(...)
folder String // INBOX, SENT
messageId String // RFC 5322 Message-ID
uid Int // IMAP UID
subject String?
fromAddress String
fromName String?
toAddresses String @db.Text // JSON Array
ccAddresses String? @db.Text
receivedAt DateTime
textBody String? @db.LongText
htmlBody String? @db.LongText
hasAttachments Boolean @default(false)
attachmentNames String? @db.Text // JSON Array
contractId Int? // Vertragszuordnung
assignedAt DateTime?
assignedBy Int?
isAutoAssigned Boolean @default(false)
isRead Boolean @default(false)
isStarred Boolean @default(false)
isDeleted Boolean @default(false)
deletedAt DateTime?
@@unique([stressfreiEmailId, messageId, folder])
}
```
#### Sicherheit
- **Passwort-Verschlüsselung**: AES-256-GCM für Mailbox-Passwörter
- **Passwort-Reset**: Neues Passwort generieren und beim Provider setzen
- **Verschlüsselungsmodi**: SSL, STARTTLS, oder unverschlüsselt
- **Selbstsignierte Zertifikate**: Konfigurierbar pro Provider
#### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| E-Mails lesen | `customers:read` |
| E-Mails senden, markieren | `customers:update` |
| Anhänge in Dokumente speichern | `customers:update` |
| Vertrag zuordnen | `contracts:update` |
| Löschen, Papierkorb | `emails:delete` |
### Frontend-Komponenten
| Komponente | Beschreibung |
|------------|--------------|
| `EmailClientTab.tsx` | Haupt-Tab mit Konto-Auswahl und Ordnern |
| `EmailList.tsx` | E-Mail-Liste mit Aktionen |
| `EmailDetail.tsx` | E-Mail-Ansicht mit Anhängen |
| `ComposeEmailModal.tsx` | Neue E-Mail / Antworten |
| `TrashEmailList.tsx` | Papierkorb-Verwaltung |
| `AssignToContractModal.tsx` | Vertragszuordnung |
| `ContractEmailsSection.tsx` | E-Mails in Vertragsansicht |
| `SaveAttachmentModal.tsx` | Anhänge in Dokumentfelder speichern |
| `SaveEmailAsPdfModal.tsx` | E-Mail als PDF in Dokumentfelder speichern |
| `InvoicesSection.tsx` | Rechnungsverwaltung in Vertragsdetails |
### Anhänge als Dokumente speichern
E-Mail-Anhänge können direkt in Dokumentfelder des CRM gespeichert werden. Über den blauen Speichern-Button (💾) neben jedem Anhang öffnet sich ein Modal mit allen verfügbaren Zielen.
#### Verfügbare Ziele
| Kategorie | Dokumentfelder |
|-----------|----------------|
| **Kunde** | Datenschutzerklärung |
| **Kunde (Gewerbe)** | + Gewerbeanmeldung, Handelsregisterauszug |
| **Ausweisdokumente** | Dokumentscan (pro Ausweis) |
| **Bankkarten** | Kartenscan (pro Karte) |
| **Vertrag** | Kündigungsschreiben, Kündigungsbestätigung, Kündigungsschreiben (Optionen), Kündigungsbestätigung (Optionen) |
> **Hinweis:** Vertragsdokumente sind nur verfügbar, wenn die E-Mail einem Vertrag zugeordnet ist.
#### Dynamische Konfiguration
Die Dokumentziele werden zentral in `backend/src/config/documentTargets.config.ts` konfiguriert. Neue Dokumentfelder werden automatisch im Modal angezeigt, ohne Frontend-Änderungen.
```typescript
// Beispiel: Neues Feld hinzufügen
{
key: 'newDocument',
label: 'Neues Dokument',
field: 'newDocumentPath', // Prisma-Feld
condition: null, // oder 'BUSINESS' für Geschäftskunden
directory: 'new-documents' // Upload-Verzeichnis
}
```
#### Warnung bei Überschreiben
Wenn bereits ein Dokument im Zielfeld vorhanden ist, wird eine Warnung angezeigt. Das vorhandene Dokument wird beim Speichern automatisch ersetzt und die alte Datei gelöscht.
### Vertragszuordnung aufheben (X-Button)
Der X-Button zum Aufheben der Vertragszuordnung erscheint nur bei **manuell zugeordneten** E-Mails. E-Mails, die direkt aus dem Vertragskontext gesendet wurden (`isAutoAssigned = true`), bleiben dauerhaft mit dem Vertrag verknüpft.
| Ansicht | Vertrags-Badge | X-Button sichtbar |
|---------|----------------|-------------------|
| **Kundenakte - Posteingang** | ✅ | ✅ immer |
| **Kundenakte - Gesendet** | ✅ | nur manuell zugeordnet |
| **Kundenakte - Papierkorb** | ✅ | je nach Original-Ordner |
| **Vertrag - Posteingang** | ✅ | ✅ immer |
| **Vertrag - Gesendet** | ✅ | nur manuell zugeordnet |
| **Vertrag - Papierkorb** | ✅ | je nach Original-Ordner |
| **Detail-Ansicht (beide)** | ✅ | nur manuell zugeordnet |
> **Hinweis:** Bei gesendeten E-Mails gilt:
> - `isAutoAssigned = true`: E-Mail wurde direkt aus dem Vertragskontext gesendet → X-Button ausgeblendet
> - `isAutoAssigned = false`: E-Mail wurde manuell dem Vertrag zugeordnet → X-Button sichtbar
### Vertragsbaum in Kundenansicht
In der Kundendetailansicht werden Verträge als **Baumstruktur** mit Vorgänger-Verknüpfung dargestellt:
```
▼ GAS-ML781A4FYXU │ Gas │ ACTIVE │ 01.01.2025 - 31.12.2026
└─ GAS-ML24GKR...│ Gas │ EXPIRED│ 05.05.2023 - 05.05.2025 (Vorgänger)
└─ GAS-OLD123 │ Gas │ EXPIRED│ 01.01.2021 - 04.05.2023 (Vorgänger)
▶ MOB-ML77W560A73 │ Mobil│ DRAFT │ 02.01.2024 - 02.01.2026
```
**Funktionsweise:**
- **Aktuellste Verträge oben** - Verträge ohne Nachfolger werden als Wurzelknoten angezeigt
- **Standardmäßig eingeklappt** - Klick auf ▶ zeigt die Vorgängerkette
- **Vorgänger eingerückt** - Mit grauem Rand und "(Vorgänger)" Label
- **Verknüpfung über `previousContractId`** - Wird beim Erstellen eines Folgevertrags automatisch gesetzt
> **Hinweis:** In der Hauptvertragsliste (`/contracts`) wird weiterhin die flache Ansicht ohne Baumstruktur verwendet.
## DSGVO-Compliance & Audit-Logging
Umfassendes Audit-Logging-System mit DSGVO-Compliance-Features.
### Audit-Protokoll
Automatische Protokollierung aller API-Zugriffe mit:
| Feld | Beschreibung |
|------|--------------|
| **Benutzer** | User-ID, E-Mail, Rolle |
| **Aktion** | CREATE, READ, UPDATE, DELETE, EXPORT, ANONYMIZE, LOGIN, LOGOUT |
| **Ressource** | Tabelle + ID + lesbare Bezeichnung |
| **Kontext** | Endpoint, HTTP-Methode, IP-Adresse, User-Agent |
| **Änderungen** | Vorher/Nachher-Werte (bei Updates) |
| **Sensitivität** | LOW, MEDIUM, HIGH, CRITICAL |
| **Integrität** | SHA-256 Hash-Kette für Manipulationsschutz |
#### Sensitivitätsstufen
| Stufe | Ressourcen |
|-------|------------|
| **LOW** | Einstellungen, Plattformen, Tarife |
| **MEDIUM** | Verträge, Provider |
| **HIGH** | Kundendaten, Benutzerdaten |
| **CRITICAL** | Authentifizierung, Bankdaten, Ausweisdokumente |
#### Zugriff
- **Einstellungen** → **Audit-Protokoll**
- Filter nach: Datum, Benutzer, Aktion, Ressource, Sensitivität
- Detail-Ansicht mit Vorher/Nachher-Diff
- Export als JSON
- Integritätsprüfung (Hash-Kette verifizieren)
### DSGVO-Dashboard
Zentrale Verwaltung für DSGVO-Anfragen unter **Einstellungen** → **DSGVO-Dashboard**.
#### Dashboard-Statistiken
- Offene Löschanfragen
- Abgeschlossene Löschungen (letzte 30 Tage)
- Datenexporte (letzte 30 Tage)
- Aktive Einwilligungen
#### Einwilligungsverwaltung (Consents)
| Consent-Typ | Beschreibung |
|-------------|--------------|
| **DATA_PROCESSING** | Grundlegende Datenverarbeitung |
| **MARKETING_EMAIL** | E-Mail-Marketing |
| **MARKETING_PHONE** | Telefonmarketing |
| **DATA_SHARING_PARTNER** | Datenweitergabe an Partner |
Einwilligungen können pro Kunde im Tab "Einwilligungen" verwaltet werden.
#### Löschanfragen (Art. 17)
Workflow für DSGVO-Löschanfragen:
| Status | Beschreibung |
|--------|--------------|
| **PENDING** | Anfrage eingegangen |
| **IN_PROGRESS** | Wird bearbeitet |
| **COMPLETED** | Vollständig gelöscht/anonymisiert |
| **PARTIALLY_COMPLETED** | Teildaten behalten (z.B. aktive Verträge) |
| **REJECTED** | Abgelehnt mit Begründung |
**Anonymisierung statt Löschung:**
- Kundendaten werden anonymisiert (nicht gelöscht)
- Aktive Verträge werden beibehalten
- PDF-Löschnachweis wird generiert
#### Datenexport (Art. 15)
Kunden können alle gespeicherten Daten als JSON exportieren:
- Stammdaten
- Adressen, Bankdaten, Ausweise
- Verträge mit Details
- Zähler und Ablesungen
- Einwilligungen
- Zugriffsprotokolle
### API-Endpunkte
```
# Audit-Logs
GET /api/audit-logs # Logs mit Filtern
GET /api/audit-logs/:id # Einzelnes Log
GET /api/audit-logs/customer/:id # Logs für Kunde
GET /api/audit-logs/export # Export (JSON/CSV)
POST /api/audit-logs/verify # Hash-Kette prüfen
GET /api/audit-logs/retention-policies # Aufbewahrungsfristen
PUT /api/audit-logs/retention-policies/:id
POST /api/audit-logs/cleanup # Manuelle Bereinigung
# DSGVO
GET /api/gdpr/dashboard # Dashboard-Statistiken
GET /api/gdpr/customer/:id/export # Kundendaten-Export
GET /api/gdpr/deletions # Löschanfragen
POST /api/gdpr/deletions # Löschanfrage erstellen
PUT /api/gdpr/deletions/:id/process # Löschanfrage bearbeiten
GET /api/gdpr/customer/:id/consents # Einwilligungen abrufen
PUT /api/gdpr/customer/:id/consents/:type # Einwilligung ändern
GET /api/gdpr/consents/overview # Consent-Übersicht
```
### Aufbewahrungsfristen
| Ressource | Frist | Rechtsgrundlage |
|-----------|-------|-----------------|
| Standard | 10 Jahre | AO §147, HGB §257 |
| Authentifizierung | 2 Jahre | Sicherheit |
| Kundendaten (HIGH) | 10 Jahre | Steuerrecht |
| Verträge | 10 Jahre | Steuerrecht |
| Allgemein (LOW) | 3 Jahre | Verjährung |
### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Audit-Logs lesen | `audit:read` |
| Audit-Logs exportieren | `audit:export` |
| Audit-Administration | `audit:admin` |
| DSGVO-Export | `gdpr:export` |
| Löschanfrage erstellen | `gdpr:delete` |
| DSGVO-Administration | `gdpr:admin` |
### Technische Details
#### Hash-Kette
Jeder Audit-Log-Eintrag enthält einen SHA-256-Hash über:
- Alle Felder des Eintrags
- Hash des vorherigen Eintrags
Dies ermöglicht die Erkennung von Manipulationen.
#### Sensitive Daten
Folgende Felder werden in Audit-Logs gefiltert:
- `password`, `passwordHash`
- `portalPasswordHash`, `portalPasswordEncrypted`
- `emailPasswordEncrypted`, `internetPasswordEncrypted`
- `sipPasswordEncrypted`, `pin`, `puk`, `apiKey`
#### Performance
- Logging erfolgt asynchron (`setImmediate`)
- 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