# 📋 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] **🧾 rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18)
  - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem
    HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie
    vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also gegen
    einen DB-Schreiber ohne Schluessel, **nicht** gegen einen Admin mit
    `audit:admin`. Der bisherige Marker hielt nur fest, DASS rehasht wurde,
    nicht WAS dabei verschwand.
  - Fix 1 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()`
    erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert:
    Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der
    Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu
    ausloesender Benutzer und IP (bisher stand dort pauschal „system“).
    Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und
    signiert – entfernen ginge nur unter Hinterlassung einer Luecke.
  - Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert
    `{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`.
    Der Pentester hatte beide bei blinder Methoden-Erkundung per POST
    unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.
  - Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit
    Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund
    (1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf.
  - **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die
    dortigen historischen Beanstandungen sind rechnerisch geglaettet
    (`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht
    jetzt aber im Marker. Staging/Prod unberuehrt.
  - Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes.
    Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab.

- [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18)
  - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben
    gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar
    nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage
    rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler.
    Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren
    Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen,
    die nie wieder mit der des Opfers kollidiert – **dauerhaft unsichtbar,
    kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02
    (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt.
  - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird
    schon beim Einloesen im selben bedingten UPDATE reserviert
    (`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits
    ausgestellten Nachfolger zurueck, statt neu zu rotieren – ohne neuen
    Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den
    Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig
    und loest den Familien-Widerruf aus.
    Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert
    (das waere wieder der Fork), sondern fail-closed als Replay gewertet.
  - Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC;
    **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs
    beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf
    des Fensters → 401, Familie widerrufen, Angreifer-Linie tot,
    `SUSPICIOUS/CRITICAL` gemeldet.
  - Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich
    (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4
    ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen.

- [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18)
  - Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp`
    gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen
    nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests).
  - Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti`
    und gehoert zu einer Sitzungs-`familyId` (neue Tabelle
    `RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die
    `jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie**
    widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt
    es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet.
  - Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn
    bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis.
  - **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3
    Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die
    Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem
    auflaufen.
  - **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben) –
    derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
    gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch
    unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war.
  - Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele
    Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest
    widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin
    erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout
    widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an.
    Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` +
    `vite build` gruen.
  - **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy)
    werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW
    gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach
    dem Deploy **einmalig neu anmelden**.

- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
  - Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
    Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern
    und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“.
  - Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY`
    – einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich
    keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.
  - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es
    faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar**
    (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als
    manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das
    gesamte Log sein.
  - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der
    Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne
    KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig.
  - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die
    FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die
    Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel
    genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein
    `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke
    vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als
    struktureller Zufall.
  - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit
    gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf
    der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel
    0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks,
    alle V3. `tsc` + `vite build` gruen.
  - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert
    (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die
    damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als
    manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env`
    (gitignored) und ist NICHT der Prod-Schluessel.

- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18)
  - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02
    selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen
    Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der
    von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht.
    Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder
    aendern (`success` false→true, `errorMessage` leeren, `resourceLabel`
    umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder
    nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“.
    Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht
    einmal ein Gap → dauerhaft unsichtbar.
  - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette
    ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab
    dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von
    der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird
    immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das
    Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein
    Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte
    Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall).
  - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**.
    Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind
    erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
    eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte.
  - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger →
    **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**;
    Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**;
    Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert
    (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen.
  - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig
    parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze
    entstehen und wuerden angezeigt. Beim hier ueblichen Deploy
    (pull + rebuild + restart) tritt das nicht auf.

- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
  - **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
    Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
    COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht
    sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine
    „100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken
    zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal.
    Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren
    fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der
    Lesevorgang den frisch festgeschriebenen Stand sieht.
    **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster:
    Release-vor-Commit → Fork, Zeilensperre → kein Fork.
  - **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After`
    (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`,
    `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit
    dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) +
    `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten
    Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2.
    Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/
    dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig.
  - **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als
    `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label
    „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH.
  - **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von
    `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der
    Parameter-Route, mit Warnhinweis im Code.
  - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein
    Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als
    eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit
    erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die
    Beweiskraft der Vergangenheit zurueckgesetzt wurde.
  - Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2;
    manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen.
  - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel
    ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber
    Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers.

- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
  - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
    unterschiedliche Befunde in einen Topf und meldete beides als
    „N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus
    wie ein Angriff – die Meldung war im Alltag nicht deutbar und wurde dadurch
    wertlos (dasselbe Muster wie beim Refresh-Rauschen).
  - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich
    veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch
    parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**)
    erweitert. `invalidEntries` bleibt als Summe erhalten
    (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend
    eindeutig; Frontend-API-Typ nachgezogen.
  - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes
    als `Authentication / LOW`. Diese Kombination traf auf keine spezifische
    Regel und fiel in die Auffangregel `*` mit 3650 Tagen – das **Rauschen
    waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage).
  - Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration
    (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit
    sie sowohl bestehende Installationen als auch Neuinstallationen erreicht.
  - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine**
    Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise
    bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde
    still die Aufbewahrungsfrist verlaengern – daher unterlassen.
  - Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile
    (`UPDATE … SET userEmail`) wird als **manipuliert** erkannt und nicht mit
    Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt
    wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen.

- [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18)
  - `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als
    zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten
    Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im
    Bestand: 05.05./07.05.2026).
  - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen
    benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB,
    wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`,
    da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der
    Verbindung zurueck in den Pool und blockiert alle Schreiber).
  - **Verworfener erster Ansatz – wichtig:** `SELECT … FOR UPDATE` auf das
    Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs
    kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch
    Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER
    Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer
    Kettenbruch – deshalb der Umbau auf den benannten Lock.
  - Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue
    Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei
    (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7
    historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst
    VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen.

- [x] **🛡️ Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18)
  - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630**
    Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf
    exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.–01.05.2026.
  - Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer
    `undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden
    **alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 –
    aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN.
  - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos
    ausloest, wird ignoriert – **echte** Manipulation ginge im Lärm unter
    (gleiches Muster wie beim Refresh-Rauschen, [R165]).
  - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten;
    `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
    wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der
    naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
    Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes
    bleiben unangetastet.
  - Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche,
    siehe naechster Punkt). Adversarial gegengetestet: Manipulation an
    `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie
    NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen
    akzeptiert. `tsc` gruen.

- [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18)
  - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder
    `/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund:
    auch `/login` und `/logout` liefen als generisches `CREATE`.
  - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter
    Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler.
    Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt
    ihn nur beim `next()`-Durchlauf wieder her – ein terminaler Handler
    (`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ
    (`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks
    liefen ins Leere → Fallback POST→CREATE + Default-Sensitivität CRITICAL.
    Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`).
  - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED`
    waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das
    `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie
    betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt.
  - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten
    (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream
    ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`,
    `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH`
    zusätzlich in die „immer loggen“-Ausnahme aufgenommen.
  - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests):
    `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag),
    `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem
    `endpoint`-Pfad und korrekten Labels. `tsc` grün.

- [x] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18)
  - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos
    `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf
    geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der
    Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter
    CRITICAL zu bleiben).
  - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zählt
    `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action →
    `LOGIN_FAILED`) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen:
    - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` →
      greift die bestehende Schwelle `≥3 TOKEN_REJECTED/5min/IP → CRITICAL`
      (securityAlert.service, kein Severity-Filter). Severity wie Access-Token:
      abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungültige
      Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafür
      `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert
      NICHT (normaler Erstbesuch).
    - **Audit-Triage:** fehlgeschlagener Refresh → Sensitivität HIGH statt LOW +
      Label „Token-Refresh abgelehnt (ungültig/abgelaufen)". Action bleibt
      bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login).
  - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grün.
  - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation
    bietet keinen Replay-Schutz (alter Token bis exp gültig), aber fail-closed
    nach Logout. Ggf. später: Refresh-Token-Jti-Blacklist / One-Time-Use.

- [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18)
  - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach
    Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /
    „Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie
    anonyme Login-Flut, war aber die eigene Session.
  - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration
    `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`).
    `determineAction()` erkennt `/auth/refresh` → eigene Action; Label
    „Sitzung verlängert (Token erneuert)"; Sensitivität in der Middleware
    explizit auf **LOW** (statt Default `Authentication → CRITICAL`).
  - Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlängert" + dezente
    Badge-Farbe (slate); Typ-Union ergänzt. `anonymous` bleibt (Endpoint läuft
    ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) –
    bewusst nicht geändert (Option 1).
  - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grün.

- [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18)
  - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/
    `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma →
    injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf
    Fremdvertrag umhängen) oder den **PK frei setzen** (stilles 200 statt 400).
    MEDIUM (Integrität; kein Cross-Tenant, staff-only, Portal 403).
  - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/
    `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet`
    war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten
    abgeglichen (minus id/contractId/verschlüsselt) – alle Diffs leer.
  - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` →
    basePrice aktualisiert, ecd.id + contractId **unverändert** (kein Reparenting).

- [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18)
  - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint
    `POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne
    Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar**
    (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei
    contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus).
  - Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet
    Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt
    solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link).

- [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18)
  - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail-
    Providern konfigurierten Domain (oder Subdomain) liegen → man trägt so keine
    verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist
    **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar);
    die Domain-Prüfung greift aber bei create UND update, falls eine gesetzt wird.
  - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service.

- [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwünscht"** (2026-08-18)
  - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`).
    Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail.

- [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14)
  - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt
    **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal
    (nur Lieferadresse) pflegbar.
  - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte
    (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewählten Lieferadresse zeigt (mit
    Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der
    Adresse.
  - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent)
    **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige
    Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert.
  - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156-
    Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address-
    Whitelist gefallen → die Eigentümer-Sektion speicherte seit `cb21a2c` nicht
    mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle
    DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus).

- [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13)
  - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen
    `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent
    zur R156-Härtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/
    `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind
    damit **nicht** mehr per Form-Update setzbar (Pfade nur noch über die
    Upload-Endpunkte).
  - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem
    unvollständigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular
    gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wären:
    `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`,
    `nextReviewDate`.
  - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte
    `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert.

- [x] **🗂️ Neuer Vertragsstatus „Gekündigt / bestätigt" + Kündigungs-Workflow** (2026-08-13)
  - Bisheriges **„Gekündigt"** umbenannt in **„Gekündigt / Bestätigung abwarten"**
    (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein
    **Kündigungsschreiben** (`cancellationLetterPath`) hochgeladen wird
    (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestätigt).
  - **Neuer Status „Gekündigt / bestätigt"** (`CANCELLED_CONFIRMED`) – automatisch,
    sobald ein **Kündigungsbestätigungsdatum** vorliegt (per Bestätigungsdokument,
    das das Datum füllt, ODER manuell) + Vertragsende = Kündigungsdatum. Hebt auch
    aus „Bestätigung abwarten" hoch.
  - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration
    `20260813200000_contract_status_cancelled_confirmed` (idempotentes
    `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter
    Logik nur bei vorhandener Bestätigung gesetzt) → `CANCELLED_CONFIRMED`.
  - **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung)
    gelten jetzt für `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten");
    „Kündigungsbestätigung fehlt"-Warnung greift dadurch weiter für CANCELLED.
    `CANCELLED_CONFIRMED` in Ladeliste + Kündigungsbestätigungs-Filter aufgenommen.
  - **Frontend:** Labels/Farben/Status-Erklärungen + Status-Dropdown in
    ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
    (CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert:
    Schreiben→CANCELLED, Bestätigung→CANCELLED_CONFIRMED+Enddatum.

- [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13)
  - Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner)
    und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber
    Integritätsschwäche – und mit `cardNumber` liegt Finanz-PII drauf).
  - **Fix:** explizite Feld-Whitelist im Service (create+update) für **BankCard,
    Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein
    `...data`/`req.body`-Spread mehr. Zusätzlich Controller-`pickBankCardFields`
    für saubere Audit-Logs (keine Phantom-Einträge injizierter Keys).
  - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}`
    → id+customerId **unverändert**, nur cardNumber gesetzt, Fremdfelder ignoriert.

- [x] **🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13)
  - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration
    `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev
    angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint.
    Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergänzt.
  - **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen
    zusätzlich (jeweils mit Copy-Button, nur wenn gesetzt):
    - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum
    - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum
      vom Kunden**
  - **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis-
    Dropdowns dieselben Detailfelder der aktuell gewählten Karte/Ausweis
    (Copy-Buttons). Selects dafür je in eigenem `<div>` gewrappt (Grid-Alignment).
  - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/
    customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder.

- [x] **📄 PDF-Viewer-Modal für Bankkarte-/Ausweis-Dokument** (2026-08-13)
  - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `<iframe>` auf
    `viewUrl(documentPath)`, inkl. „In neuem Tab" + „Herunterladen"). Nutzt den
    bestehenden `/api/files/download`-Endpoint (Per-File-Ownership-Check) – kein
    neuer Zugriffspfad. `<iframe>` erlaubt via CSP `default-src 'self'`
    (`object-src 'none'` bleibt, daher iframe statt embed/object).
  - **Vertragsansicht** (ContractDetail): Button (FileText-Icon) im Card-Header
    von „Bankkarte"/„Ausweis" – **nur wenn `documentPath` gesetzt**.
  - **Vertrag bearbeiten** (ContractForm): Button neben dem Label „Bankkarte"/
    „Ausweis" – **nur wenn die aktuell gewählte Karte/Ausweis ein `documentPath`
    hat** (`e.preventDefault/stopPropagation`, da im `<label>`).

- [x] **📇 Vertragslisten: Karteninhaber + Mobilfunknetz + Kündigung (rot)** (2026-08-13)
  - In **beiden** Vertragslisten (Kundenakte-Baum + Hauptmenü `/contracts`): bei
    Mobilfunkverträgen mit Rufnummer zusätzlich **Karteninhaber** (`SimCard.cardUser`
    der angezeigten SIM, nur wenn gesetzt) und **Netz** (`mobileNetwork` →
    Telekom/Vodafone/Telefónica (o2)) inline hinter der Rufnummer.
  - Verträge mit **Kündigungsbestätigung**: `cancellationConfirmationDate` als
    eigene Zeile in **roter, fetter Schrift** („Kündigungsbestätigung zum …").
  - Backend: `getContractTreeForCustomer` + `getAllContracts` liefern jetzt
    `mobileNetwork` + `cardUser` (Tree zusätzlich `cancellationConfirmationDate`;
    Flat-Liste hat Scalars ohnehin via include). Shared-Helper
    `getContractTypeInfo` um `cardUser`/`network` erweitert (+ `mobileNetworkLabel`).
  - **3 Render-Pfade** ergänzt (waren getrennt!): Kundenakte-Baum, die
    Staff-**Tabelle** in `/contracts` (flache `getAll`-Liste) UND der
    Portal-Baum in `/contracts`. Der erste Anlauf hatte nur die Baum-Pfade –
    die Staff-Tabelle (Haupt-Ansicht für Mitarbeiter) fehlte und wurde nachgezogen.
  - **Vertragssuche** (`?search=`) durchsucht jetzt auch den **Karteninhaber**
    (`simCards.cardUser`) – zusätzlich zu Rufnummer/SIM-Nummer/IMEI (Pentest-R150-
    Randnotiz, fachlich gewünscht). Portal-Suche bleibt durch Scoping begrenzt.

- [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12)
  - **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests
    (`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es
    KEINEN generellen Limiter – nur Login/Passwort-Reset/Staff-ReAuth/Consent;
    authentifizierte Endpoints waren gegen Enumeration/DoS ungedrosselt.
    Key = **nur IPv6-/56-normalisierte IP** (bewusst nicht IP+User: der
    User-Claim wäre hier nur unverifiziert lesbar → Bypass per Fake-Token).
    Limit per Env `API_RATE_LIMIT_PER_MIN` (Default **1200/min**, Floor 60),
    `/api/health` ausgenommen. Kein SecurityEvent pro Block (sonst Flood-
    Amplification). Verifiziert (60×200→429, health bleibt 200).
  - **Deckt den offenen IPv6-Test mit ab:** außerhalb der Auth-Pfade greift jetzt
    ebenfalls ein IPv6-normalisierter Limiter.
  - **BLZ-Poisoning-Guard:** prüft jetzt JEDEN Dataset-Eintrag (nicht nur die
    erste Zeile) – 8-stellige BLZ, Wert `[Name]` oder `[Name,BIC]`,
    `next.remove` = BLZ-Liste, `next.valid` = gültiges Datum. Banken **ohne BIC**
    (`[Name]`, z.B. BLZ 60050009) korrekt zugelassen → `lookupBlz` liefert `bic:''`.

- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12)
  - Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`):
    zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken,
    zuletzt geprüft/aktualisiert, neueste verfügbare Version + „Update
    verfügbar"-Badge, letzter Fehler). Button **„Jetzt prüfen & aktualisieren"**
    (+ „Neu laden erzwingen"). Auto-Update-Schalter + **Intervall in Tagen** (1–365).
  - **Echtes Laufzeit-Update** statt Rebuild: Ein Scheduler (täglich 03:30 +
    Catch-up 90s nach Start) prüft, ob laut Intervall fällig, und lädt dann den
    aktuellen Datensatz. Quelle = npm/jsDelivr (`bankdata-germany` current.json +
    next.json), gespeichert im **Bind-Mount-Volume** `BANKDATA_DIR`
    (`./data/bankdata` → `/app/bankdata`, neu in docker-compose + Dockerfile mkdir
    + .env.example).
  - **Lookup-Vorrang:** Volume-Datensatz (aktuell) vor ins Image gebackenen Daten
    (`bankdata-germany`, Fallback). Kombiniert current+next-Delta wie das Paket.
    Kein Fremdcode – nur JSON wird gelesen. Validierung (≥1000 Einträge, Format)
    verhindert, dass Müll/HTML den guten Datenstand überschreibt (atomarer
    Write via tmp+rename).
  - **Datenschutz:** Der Updater sendet KEINE Kundendaten – lädt nur eine
    öffentliche Datendatei. Beim IBAN-Lookup verlässt weiterhin nichts den Server.
    Auto-Update ist abschaltbar; ohne Egress/bei Fehler greift der Builtin-Stand.
  - Endpoints: `GET /api/settings/blz` (settings:read), `POST /api/settings/blz/update-now`
    (settings:update, `?force=1`). Settings-Keys `blzAutoUpdateEnabled` (default true),
    `blzUpdateIntervalDays` (default 30) in Whitelist + validiert.
  - **Damit entfällt die manuelle `npm update`-Wartung** aus dem vorherigen Punkt.

- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
  - Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
    hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
    dabei die **IBAN-Prüfziffer (mod-97)** → erkennt Tippfehler.
  - **Offline / datenschutzfreundlich:** kein Dritt-Dienst. Nachschlag im
    eigenen Backend über die **Bundesbank-Bankleitzahlendatei**
    (`bankdata-germany`) + `ibantools` für die Prüfziffer. Die IBAN verlässt
    nie den Server; zurück kommen nur öffentliche Bankverzeichnis-Daten.
  - Endpoint `POST /api/bank-cards/iban-lookup` (nur eingeloggt). Antwort:
    `{valid:false}` (Prüfziffer falsch) · `{valid:true, bic, bankName, blz}`
    (DE-Bank gefunden) · `{valid:true, bic:null,…}` (gültig, aber Nicht-DE/
    unbekannte BLZ → Info-Toast).
  - Leeres IBAN-Feld → **OK-Messagebox** „IBAN erforderlich" statt Anfrage.
  - **Wartung:** `bankdata-germany` + `ibantools` müssen ~quartalsweise per
    `npm update` aktualisiert werden (Bundesbank-BLZ ändert sich mehrmals/Jahr).

- [x] **📌 Update-Banner klebt oben fest (sticky)** (2026-08-12)
  - „Neue Version verfügbar"-Banner scrollte weg; jetzt `sticky` an der
    Viewport-Oberkante. Banner meldet seine Höhe als CSS-Var `--app-banner-h`,
    an der die Sticky-Header von ContractDetail/ContractForm einrasten
    (`top-[var(--app-banner-h,0px)]`) → keine Überdeckung.

- [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12)
  - Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite
    `/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag,
    Betrag, Datum, PDF), Suche + Pagination.
  - **Scoping:** neuer Endpoint `GET /credit-notes` (NICHT staff-only wie die
    übrigen Credit-Note-Endpoints): Staff sieht **alle** Belege aller Kunden,
    Portal-Kunden **nur eigene + vertretene** (Vollmacht via `hasAuthorization`).
    `customerIds` kommt aus dem JWT (nicht aus Query/Body → nicht manipulierbar).
    Für Portal wird `receiptPath` aus der Response entfernt (Belege bleiben
    staff-only). Verifiziert (Staff alle, Portal nur eigene).
  - **Fail-closed (Pentest R144):** Ein Portal-Token wird jetzt IMMER gescoped;
    fehlt wider Erwarten die `customerId` (null/0), ergibt das eine **leere
    Menge** statt des Staff-Zweigs. Verifiziert (`customerIds=[]` → 0 Belege).
- [x] **🚫 Gutschrift nur mit Empfängeradresse (Rechnung > Liefer)** (2026-08-12)
  - Beim Klick auf „Gutschrift anlegen" wird geprüft, ob der Vertrag eine
    Empfängeradresse hat: **Rechnungsadresse hat Vorrang, sonst Lieferadresse**.
    Ist **keine** von beiden hinterlegt → **Modal-OK-Meldung** statt Formular.
  - Backend Defense-in-Depth: `createCreditNote` wirft **400**, wenn weder
    `billingAddressId` noch `addressId` gesetzt ist. `getCreditNoteDefaults`
    liefert `hasRecipientAddress` fürs Frontend. Verifiziert.
- [x] **🧾 Gutschrift: Beleg-Upload auch für Sachwerte + kein Unterschriftsblock bei Geld** (2026-08-12)
  - **Beleg-Upload jetzt für beide Arten:** bei Geld die Überweisungsbestätigung,
    bei Sachwert das **unterschriebene Dokument**. `ReceiptControls` in der Liste
    für Geld UND Sachwert (Label „Überweisungsbeleg" bzw. „Unterschr. Dokument").
    Endpoint `/upload/credit-notes/:id/receipt` war schon typ-agnostisch.
  - **PDF-Unterschriftsblock nur noch bei Sachwert:** eine Geld-Überweisung wird
    nicht unterschrieben (Beleg = hochgeladene Überweisungsbestätigung). Bei Geld
    entfällt der Unterschrift/Ort-Block im PDF; im Formular sind „Ort" +
    „Unterschrift am" bei Geld ausgeblendet. Verifiziert (beide PDFs 1 Seite).
- [x] **📦 Lieferschein-Nummernkreis für betragslose Sachwerte (GoBD)** (2026-08-12)
  - Pentester-Hinweis R142: Beim Übergang Geld→betragsloser Sachwert wurde die
    schon vergebene Gutschriftsnummer auf null gesetzt → **Lücke** in der GS-Serie.
  - Lösung (User): betragsloser Sachwert = **Lieferschein** mit **eigener
    Lieferscheinnummer** aus **separatem Nummernkreis** (`DeliveryNoteNumberRange`,
    Default-Präfix „LS-"). `CreditNote.deliveryNoteNumber` (nullbar, unique) +
    Migration.
  - **Gap-frei:** Nummern werden lazy pro Serie vergeben und **nie wieder
    freigegeben**. Übergänge (Geld↔betragsloser Sachwert) behalten die jeweils
    schon vergebene Nummer der anderen Serie reserviert am Beleg → kein
    Doppelverbrauch, keine Lücke. Angezeigt/erzeugt wird via `effectiveNumber`
    je nach aktuellem Typ die passende (LS bzw. GS).
  - Settings-Seite verwaltet jetzt **beide** Nummernkreise (Gutschrift +
    Lieferschein); neue Endpunkte `GET/PUT /credit-notes/delivery-note-number-
    range`. PDF-Titel „Sachwert-Übergabe", Dateiname `lieferschein-…`.
  - Verifiziert: Sachwert 0 → LS-Nr, GS-Zähler unberührt; Geld → GS-Nr;
    Übergänge behalten Nummern (kein Neuverbrauch).
- [x] **🔐 Portal-Passwort: Reveal/Send prüft Konsistenz (kein „falsches" PW)** (2026-08-11)
  - Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und
    verschlüsseltes Reveal-Feld out-of-sync → Support hätte ein Passwort
    vorgelesen, das beim Login scheitert.
  - **Analyse:** Alle aktuellen Schreibpfade sind konsistent (beide Felder
    zusammen ODER encrypted=null ODER Rehash desselben Passworts) – der aktuelle
    Code erzeugt keinen Desync. Ursache war Altlast/manueller DB-Eingriff.
  - **Fix (Belt-and-Suspenders):** `getCustomerPortalPassword` liefert jetzt
    `{status: ok|none|desync}` und prüft den entschlüsselten Klartext per
    `bcrypt.compare` gegen den Login-Hash. Bei `desync` (oder Entschlüsselungs-
    fehler) geben **weder Reveal noch Send-Credentials** das Passwort aus,
    sondern 409 „Dateninkonsistenz – bitte neu setzen". Reveal-Read wird mit
    Status auditiert.
  - **Diagnose-Script** `scripts/check-portal-password-sync.ts`: scannt alle
    Portal-Kunden auf Hash-vs-Encrypted-Desync (nur Diagnose) – für Prod, da
    der Pentester kein FS-Zugriff hat. Verifiziert (desync/ok/none-Fälle).
- [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09)
  - Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
    dann findet **keine Rechnungsstellung** statt – der Kunde hat den
    Gegenstand einfach als Subvention erhalten. Beträge bleiben 0, keine USt.
  - Backend: `amount` leer → 0; Sachwert erlaubt 0, **Geld verlangt Betrag > 0**
    (400 sonst). `vatRelevant` wird bei 0 erzwungen-false.
  - PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein
    Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und **kein ZUGFeRD-
    Embedding** (es ist keine Rechnung). Mit Betrag → unverändert ZUGFeRD.
  - Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange
    kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)".
  - Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 →
    ZUGFeRD).
  - **Keine Gutschriftsnummer für betragslose Sachwerte:** `number` ist jetzt
    nullbar (Migration `MODIFY … NULL`, UNIQUE bleibt); ein betragsloser
    Sachwert (Lieferschein) bekommt **keine** Nummer und verbraucht den
    Nummernkreis-Zähler **nicht**. Update-Übergänge: wird betragslos → Nummer
    entfernt; bekommt nachträglich einen Betrag → jetzt Nummer vergeben. PDF/
    Liste/Audit zeigen dann „Sachwert-Übergabe"/„Beleg #id". Verifiziert
    (Zähler bleibt bei Sachwert 0 stehen, springt nur bei echten Gutschriften).

- [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07)
  - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit
    `previousContractId` an – der Verlust passierte im Frontend: das
    „Vorgänger-Vertrag"-Dropdown holte die Verträge über `getAll`, das
    **DEACTIVATED standardmäßig ausblendet**. Beim Bearbeiten des Folgevertrags
    war der (deaktivierte) Vorgänger daher nicht als Option vorhanden → beim
    Speichern ging die Verknüpfung verloren. Fix: `getAllContracts` +
    Controller + `contractApi.getAll` um `includeDeactivated` erweitert; das
    Vorgänger-Dropdown nutzt es (`includeDeactivated: true`) und markiert
    deaktivierte Verträge mit „· deaktiviert". Verifiziert.
  - **Kundendaten-Modal (Schnellansicht):** zeigt jetzt zusätzlich die
    **Lieferadresse** und (falls abweichend) **Rechnungsadresse** des geöffneten
    Vertrags, die **Stressfrei-Adresse des Vertrags** (einzeln) und darunter
    **alle weiteren Stressfrei-Adressen** des Kunden. `CustomerInfoModal` nimmt
    optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId),
    Aufrufe aus ContractDetail + ContractForm übergeben den Vertragskontext.

- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
  - **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
    Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate`
    übers Formular) und/oder als **Dokument** (Upload) – wird der Vertrag von
    ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**.
    Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur
    aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte
    Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei
    neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht
    zurückgesetzt werden). Verifiziert.
  - **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit
    hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status
    ACTIVE/DRAFT/CANCELLED/EXPIRED (Abgelaufen nachträglich ergänzt). Eigene Liste `cancellationConfirmations` im
    Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht
    auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
  - **Nachbesserungen Pentest R138:**
    - `endDate` wird bei **DRAFT** nicht mehr gesetzt (Entwurf = Vorlage);
      nur ACTIVE/CANCELLED bekommen Vertragsende = Kündigungsdatum.
    - `cancellationConfirmationDate`/`-OptionsDate` im Update-Pfad über
      `validateOptionalIsoDate` normalisiert → akzeptiert Datum-only UND
      volles ISO (vorher lehnte Prisma Datum-only ab), konsistent zum Upload.
  - **Gutschrift-Dateien:** Überweisungsbelege (`credit-note-receipts`) sind
    jetzt **staff-only** (neuer FileOwner-Typ `contract-staff`, Portal 403);
    das Gutschrift-**PDF** (`credit-notes`) bleibt vertragsbasiert (eigener
    Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload für Portal war
    bereits per `blockPortal`/Portal-403 gesperrt.
    - **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden
      PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird
      geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr.
    - **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts`
      konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`),
      ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile`
      (creditNote-, upload-, customer-Service). Reihenfolge umgestellt:
      erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt
      die Datei). Verifiziert.

- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06)
  - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
    (Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
    Betrag, Datum (änderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten",
    bei Geld Überweisungsbeleg (Upload, Phase 2).
  - **USt pro Gutschrift wählbar** (`vatRelevant` + Basis NETTO/BRUTTO +
    `vatRate`) → Netto/USt/Brutto werden berechnet & getrennt gespeichert
    (ZUGFeRD-tauglich). `PRIVAT`/`FIRMA` aus Kunde vorbelegt. Verifiziert:
    nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200.
  - **Nummernkreis** (`CreditNoteNumberRange`, Einzel-Zeile, in Settings
    verwaltbar): Präfix/Jahr/Separator/Padding/Startzähler/Jahres-Reset.
    Nummernvergabe transaktional mit `SELECT … FOR UPDATE` (keine Doppel-
    vergabe). Beispiel `GS-2026-0001`.
  - Neu: Model `CreditNote` + `CreditNoteNumberRange` + Enums + Migration
    (IF NOT EXISTS, auf Dev angewandt). Service/Controller/Routes:
    `GET/POST /contracts/:id/credit-notes`, `GET .../defaults`,
    `GET/PUT/DELETE /credit-notes/:id`, `GET/PUT /credit-notes/number-range`.
    Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE
    auditiert.
  - **Phase 2a (erledigt):** Kunden-Feld `vatExempt` (Kleinunternehmer §19,
    Checkbox nur für Firmenkunden) + Migration. Gutschrift-Default `vatRelevant`
    wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst
    aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
    ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
  - **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene
    Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein
    echter Tab-Balken; ggf. später umbaubar): Liste + Anlegen/Bearbeiten
    (Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort,
    Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, über
    `/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergänzt).
    Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number-
    range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`.
  - **Phase 3a (erledigt):** `CompanyProfile` (Firmenstammdaten/Absender) +
    Migration + Settings-Seite `/settings/company-profile`.
  - **Phase 3b Teil 1 (erledigt):** Gutschrift-**PDF** (pdfkit) mit Absender
    aus CompanyProfile, Empfänger, Netto/USt/Brutto, Sachwert/„Ware erhalten"
    bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint
    `POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen).
    PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal
    verifiziert (valides PDF).
  - **Auszahlungskonto (nur GELD):** `CreditNote.payoutBankCardId` +
    Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN
    Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung
    auf ein anderes Konto gehen kann. Server prüft, dass die Bankkarte dem
    Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
    Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
    Auszahlungskonto.
  - **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts`
    erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**;
    Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts`
    bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB-
    OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B +
    Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts**
    (im Repo unter `backend/assets/fonts`, Pflicht für PDF/A). Dockerfile
    kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert
    (1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet,
    XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
  - **⚠️ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator**
    prüfen (am besten auf Staging mit echten Firmendaten). Feinheiten
    (Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf.
    nach dem ersten Validator-Lauf nachziehen.
  - **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
    Steuerberater klären – Modell/XML decken beide über `vatRelevant` ab.
    ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.

- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
  - „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
    Neu-Formular unter `/contracts/new?copyFrom=<id>&customerId=<cid>` mit
    ALLEN Daten des Quellvertrags vorbefüllt. Beim Speichern entsteht ein
    frischer, unabhängiger Vertrag (kein `previousContractId`-Link, kein VVL).
  - Use-Case: neuer Mobilfunk-/o.ä. Vertrag ist meist fast identisch – nur
    Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif ändern sich.
  - Geleert im Kopier-Modus (Integrität / Eindeutigkeit): Status→DRAFT,
    Vorgänger-Link, alle Datumsfelder (Start/Ende/Installation/Kündigungs-
    bestätigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern.
    **Nicht** übernommen: SIM-Karten & Rufnummern (hardware-/vertrags-
    spezifisch, eindeutige Nummern) – inkl. der Legacy-Scalar-Felder
    `phoneNumber`/`simCardNumber` (Pentest R135/136: die wurden zunächst
    noch kopiert → zwei getrennte Verträge hätten dieselbe Rufnummer tragen
    können; jetzt im Leer-Block). **Behalten** wird der Portal-Zugang
    (Benutzername + Stressfrei-Verknüpfung/„nicht benötigt") – bei gleichem
    Anbieter oft identisch; nur das verschlüsselte Passwort wird nicht ins
    Feld geladen (wie beim Bearbeiten). Rest (Anbieter/Tarif, Preise, alle
    Detailfelder, Notizen) bleibt als Vorlage.
  - Rein Frontend (`ContractForm` copy-Mode + Button in `ContractDetail`);
    kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner
    „Kopie … neuer eigenständiger Vertrag" im Formular.

- [~] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30)
  - Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
    ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL`
    mit Access-Token → 60s-Download-Token → Blob/`items.add(File)`).
  - **Ergebnis: harte Plattformgrenze.** Ein Browser kann einer fremden
    Desktop-App keine echte lokale Datei zum Anhängen übergeben. URL-Drag →
    Thunderbird speichert nur einen Link (0 Bytes, lädt beim Senden nach →
    Fehler). Datei-Drop in den Nachrichtentext → nur Dateiname als Text.
    Auf die Anhang-Leiste gezogen (Thunderbird/Linux/Chromium) → ebenfalls
    kein echter Anhang. Webmail im Browser → gar keine Datei möglich.
  - **Entscheidung (User):** komplett wieder entfernt. Download- + Anzeigen-
    Button decken den Bedarf zuverlässig ab. Komponente `PdfDragButton`
    gelöscht, `fileUrl()`-Token-Param zurückgebaut. Pentest R131 damit
    gegenstandslos (kein Drag-Feature mehr).
  - **Bleibt erhalten:** die separaten Copy-Buttons für IBAN (Bankkarte) und
    Ausweisnummer im Vertragsformular (s.u.).

- [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30)
  - In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label,
    kopiert nur den reinen Wert der aktuell gewählten Option: bei Bankkarte
    die **IBAN ohne Namen**, bei Ausweis die **Ausweisnummer ohne (TYP)**.
  - Button erscheint nur, wenn eine Karte/ein Ausweis ausgewählt ist
    (`watch('bankCardId')`/`watch('identityDocumentId')` → Lookup).

- [x] **📧 Stressfrei-Mailbox: Passwort beim Anlegen verbindlich setzen** (2026-07-30)
  - Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)"
    wird jetzt immer ein frisch generiertes Passwort **explizit am Provider
    gesetzt** (`updateMailboxPassword` direkt nach dem Provisioning).
  - Behebt den Fall, dass die Adresse beim Provider bereits als reine
    Weiterleitung existierte: dann kehrte `provisionEmailWithMailbox` früh
    mit success zurück, ohne je ein Postfach-Passwort zu setzen – im CRM lag
    ein verschlüsseltes Passwort, das der Provider nicht kannte → IMAP/SMTP-
    Login schlug fehl. Jetzt stimmen CRM und Provider garantiert überein.
  - **Nachtrag Pentest R130:** Erster Versuch (`updateMailboxPassword`)
    setzte bei Plesk nur `-passwd`, ließ die Adresse aber auf
    `-mailbox false` → Mailbox nie aktiviert, Login scheiterte weiter.
    Jetzt `enableMailboxForExistingEmail` (`-mailbox true -passwd ...`),
    deckt beide Fälle idempotent ab.
  - Tab-Leiste bei vielen Tabs: `flex-wrap` statt Overflow aus dem Rahmen.
  - Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.).

- [x] **🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)**
  - Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal),
    **ohne** Consent-Pflicht nutzbar. Zwei Abschnitte:
    1. „<Kunde> wurde an Board geholt durch:" – **max. 1** Werber
       (DB-Unique auf `recruitedId`).
    2. „<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.
  - Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche über
    Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in,
    Kind, Freund/Kumpel, … „Bitte auswählen!"). Löschen + Externtab-Link
    zur Kundenakte pro Zeile.
  - **Bidirektional aus einem Datensatz:** trägt man in Kunde A „geworben
    durch B" ein, erscheint A automatisch bei B unter „hat geworben".
    Von beiden Akten hinzufügbar/löschbar.
  - Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId`
    @unique, `relationship`) + Migration. Beziehungs-Whitelist server-
    seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
    explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
    auditiert.
  - **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/
    Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa
    erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro
    Zeile (vor der Mülltonne): ändert Beziehung und/oder Gegen-Kunden;
    neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel-
    Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).

- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
  - Cache-Header waren schon optimal (index.html `no-store`, Assets
    `immutable`) – aber eine laufende SPA holt sich nach einem Deploy
    nicht von selbst neuen Code. Neuer `useVersionCheck`-Hook holt die
    (immer frische) index.html periodisch + beim Zurückkehren zum Tab,
    vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei
    Änderung erscheint ein dezentes Banner „Neue Version verfügbar →
    Jetzt neu laden" (`UpdateBanner`, oben im Layout).
  - Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op
    (keine `/assets/<hash>`-Dateien → leere Signatur).

- [x] **👁 Kundenportal: Toggle „Deaktivierte Verträge anzeigen"**
  - Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon;
    jetzt auch in der Kundenportal-Vertragsübersicht (`ContractList`,
    Portal-Zweig). Button nur bei `isCustomerPortal`, reicht
    `includeDeactivated` an die `getTreeForCustomer`-Queries durch
    (`showDeactivated` im Query-Key → frischer Fetch beim Umschalten).
  - Security: nutzt denselben Endpoint hinter `canAccessCustomer`
    (R120) – Portal-Kunde sieht nur eigene/vertretene Bäume, das Flag
    weitet nur den Status-Filter innerhalb der erlaubten Daten. Keine
    neue Exposition.

- [x] **🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)**
  - `mobileNetwork` akzeptierte serverseitig jeden (stripHtml-bereinigten)
    String – das Frontend-Dropdown beschränkte nur clientseitig. Jetzt
    Whitelist `TELEKOM|VODAFONE|TELEFONICA` in `contract.service.ts`
    (`normalizeMobileNetwork`), angewandt in Create- UND Update-Pfad
    (Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/
    leere Werte → null.

- [x] **🔒 Rate-Limiting: IPv6-Bypass-Härtung (ipKeyGenerator)**
  - Die Rate-Limiter mit eigenem `keyGenerator` (`loginRateLimiter`,
    `staffPasswordReAuthLimiter`) keyten auf die **volle** `req.ip`.
    Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block (/56–/64)
    pro Versuch eine neue Adresse nehmen und so das Per-IP-Limit
    (Login-Bruteforce, Passwort-Set-Reauth) umgehen.
  - Fix: `req.ip` in beiden `keyGenerator` durch `ipKeyGenerator(...)`
    (express-rate-limit v7) ersetzt → IPv6 wird auf das Subnetz
    normalisiert (Library-Default /56), IPv4 unverändert. Verifiziert:
    zwei verschiedene IPv6 im selben /56 ⇒ derselbe Key.
  - Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, Consent)
    machten das schon über den Library-Default – jetzt konsistent.
    (Ersetzt den verworfenen aria-WIP; auf aktuellem Stand neu gebaut.)

- [x] **🐞 Spam-Tab: Anhänge aus Junk-Ordner (Pentest R124-Fund)**
  - Beim Spam-Feature wurden `moveEmailToTrash`/`restoreEmailFromTrash`
    auf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen
    im Controller (`downloadAttachment`, `saveAttachmentTo`,
    `saveAttachmentAsInvoice`, `saveAttachmentAsContractDocument`)
    bestimmten den IMAP-Ordner weiter hart als
    `email.folder === 'SENT' ? 'Sent' : 'INBOX'`. Für SPAM-Mails landete
    das fälschlich auf INBOX. Da IMAP-UIDs pro Ordner vergeben sind:
    404 (Anhang nicht gefunden) oder – bei UID-Kollision – der falsche
    Anhang aus INBOX (z.B. als Rechnung/Vertragsdokument abgelegt).
    Kein Cross-Customer-Leak (bleibt im selben Postfach), aber echtes
    Datenintegritätsproblem.
  - Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in
    `moveEmailToTrash` + `findJunkFolderPath` importiert.
  - Randfall (Pentester-Nachtrag): findet sich der Junk-Ordner nicht
    (mehr), wird jetzt ein klarer Fehler zurückgegeben statt still auf
    INBOX/`'Junk'` zu raten – in allen 4 Controller-Funktionen (404
    „Anhang nicht abrufbar") und den 2 Service-Funktionen
    (`moveEmailToTrash`/`restoreEmailFromTrash`). Kein `undefined` mehr
    Richtung IMAP-Lib.

- [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab**
  - Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client,
    zeigt den Junk-/Spam-Ordner des gewählten Postfachs – damit
    fälschlich als Spam eingestufte Mails auffindbar sind.
  - Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration
    `20260727110000_email_folder_spam`, Enum-Wert angehängt). IMAP-Sync
    ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` +
    Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct
    ihn zusätzlich als `dbFolder=SPAM`. `getCachedEmails` +
    `getFolderCountsForAccount` um SPAM erweitert.
  - Papierkorb-Verschieben/Wiederherstellen für Spam-Mails nutzt den
    tatsächlichen Junk-Pfad als Quell-/Zielordner.
  - Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben
    List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam
    möglich (Rettung legitimer Mails).

- [x] **📱 Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif**
  - Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
    sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswählen
    (leer), Telekom, Vodafone, Telefónica.
  - Neues Feld `MobileContractDetails.mobileNetwork` (String, nullable,
    speichert TELEKOM/VODAFONE/TELEFONICA) + Migration
    `20260727100000_mobile_network` (`ADD COLUMN IF NOT EXISTS`).
    String statt Enum, damit weitere Netze ohne Migration möglich sind.
  - Anzeige in der Vertragsansicht (Mobilfunk-Details) mit lesbarem
    Netz-Namen.

- [x] **🐞 Lieferbestätigung: eingegebenes Datum ändert Vertragsbeginn nicht**
  - Beim Upload einer Lieferbestätigung mit Datum wurde `startDate` nur
    gesetzt, wenn es noch LEER war (`if (!contract.startDate)`). Hatte
    der Vertrag schon ein (geschätztes) Beginndatum, blieb es trotz
    eingetragenem Lieferdatum stehen.
  - Fix in `maybeActivateOnDeliveryConfirmation`: ein explizit
    eingegebenes Lieferdatum überschreibt den Vertragsbeginn jetzt IMMER
    (die Lieferbestätigung ist das maßgebliche tatsächliche Startdatum).
    Der Fallback „heute" (kein Datum eingegeben) füllt weiterhin nur ein
    leeres Feld, um ein echtes Datum nicht versehentlich zu überschreiben.
    No-op + Audit-Log unverändert. Frontend schickte das Datum bereits
    mit und lädt den Vertrag nach Upload neu – kein FE-Change nötig.

- [x] **🎂 Anstehende Geburtstage auch im Dashboard**
  - Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit.
    Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin,
    `enabled: !isCustomer`), gleiche `birthdayApi.getUpcoming(7, 30)`-
    Query + identische Karten-Darstellung (Heute/vergangen/kommend,
    Link auf die Kundenakte). Kein Backend-Change nötig.

- [x] **🔴 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId**
  - `POST /api/audit-logs/verify` meldete ~73% der Einträge als
    manipuliert. Kein echtes Tampering, sondern ein Bug in
    `generateHash`: `resourceId` war beim Schreiben oft `undefined`
    (middleware-Einträge ohne Route-ID). `JSON.stringify` LÄSST einen
    undefined-Wert weg → Hash ohne `resourceId`-Key. In der DB landet
    der Wert aber als `NULL`; `verifyIntegrity`/`rehashAll` lasen ihn
    als `null` zurück und `JSON.stringify({resourceId:null})` schrieb
    ihn REIN → anderer Hash → Fehlalarm für JEDE leere-resourceId-Zeile.
    Die vom Pentester gefundene `durationMs`-Korrelation war ein Proxy:
    middleware-Einträge (durationMs gesetzt) haben oft kein resourceId.
  - Fix: `generateHash` lässt nullish `resourceId` weg – reproduziert
    exakt das historische Schreibverhalten. Kein Caller übergibt je
    `null` explizit (verifiziert) → alle Bestands-Hashes matchen ohne
    Rehash. Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära
    (id>4141) 482/482 valide (vorher alle 289 null-Zeilen geflaggt).
  - Separat/vorbestehend: Einträge VOR Commit `fd55742` („complete new
    audit system") nutzen ein altes Hash-Schema und wurden nie neu
    baselined → scheitern unabhängig davon. Remediation: einmalig
    `POST /api/audit-logs/rehash` (Admin). Nicht Teil dieses Fixes.

- [x] **🔧 Pentest R120 – Audit-Log `/:id` mit nicht-numerischer ID → 500 statt 400**
  - Pentester stiess beim Suchen eines `verify-integrity`-Endpoints auf
    einen 500er. Ursache: `GET /api/audit-logs/verify` matcht `GET /:id`
    (das echte Verify ist `POST /verify`), `parseInt("verify")` = NaN →
    Prisma `findUnique({ where:{ id: NaN }})` wirft → 500. Gleiche
    „400 statt 500"-Klasse wie R64.1/R104.1.
  - Fix: `Number.isNaN`-Guard in `getAuditLogById`,
    `getAuditLogsByCustomer` und `updateRetentionPolicy` → sauberer 400.

- [x] **🔴 Pentest R120 – CRITICAL IDOR: Vertragsbaum fremder Kunden lesbar**
  - `GET /api/contracts?tree=true&customerId=<fremd>` returnte für
    Portal-User den vollständigen Vertragsbaum eines beliebigen
    Fremdkunden (Name, Kundennummer, Vertragsnummern, Tarife). Der
    `tree=true`-Zweig im Controller returnte **früh**, bevor die
    Portal-User-`customerIds`-Filterung unten für die flache Liste
    griff. Vorbestehender Bug – der Toggle „Deaktivierte anzeigen"
    (siehe unten) hat ihn nur sichtbarer gemacht, weil selbst
    archivierte Fremdverträge mit auftauchten.
  - Fix: `canAccessCustomer(req, res, customerId)` vor dem frühen
    Return ([contract.controller.ts:80](../backend/src/controllers/contract.controller.ts#L80)).
    Prüft eigene Customer-ID + vertretene MIT Live-Vollmacht, sendet
    selbst die 403. Staff (Nicht-Portal) passiert unverändert. Gleiches
    Muster wie Pentest 56.3 bei update/delete.
  - Zusatzbefund (Pentester): liegen gebliebenes `<script>`-Test-
    Artefakt in `providerName`/`tariffName` eines Staging-Vertrags.
    Nicht exploitierbar – keine der 9 `dangerouslySetInnerHTML`-Stellen
    rendert Vertrags-Provider/Tarif-Namen, React escaped sie als
    Text. Neue Writes werden zusätzlich per `sanitizeContractBody`
    (stripHtml) entschärft; der Altwert stammt aus DB-Direkteingabe.
  - Nachtrag (Retest): Der abgewehrte Zugriff wird nun in ZWEI Streams
    protokolliert. Bisher nur `SecurityEvent` (`ACCESS_DENIED`, sichtbar
    unter `/api/monitoring/events`, aber löschbar + nicht hash-verkettet)
    – der Pentester suchte im AuditLog und fand nichts. `emitAccessDenied`
    schreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag
    (`action: READ`, `resourceType: 'AccessDenied'`, Sensitivity HIGH)
    für alle `canAccess*`-403. Meine ursprüngliche Formulierung „landet
    im Audit" war die falsche Tabelle – jetzt stimmt sie.

- [x] **👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"**
  - Der Vertragsbaum beim Kunden (`CustomerDetail` → Tab Verträge)
    blendete `DEACTIVATED`-Verträge komplett aus. Da der jeweils
    aktuellste Vertrag die Baumwurzel ist und Vorgänger als Children
    hängen, verschwand eine ganze Kette aus der Ansicht, sobald die
    Wurzel deaktiviert wurde – genau das passierte, als ein aktiver
    Folgevertrag gelöscht und der Vorgänger vorher auf „deaktiviert"
    gesetzt worden war. Der Vertrag „verschwand" scheinbar, war aber
    nur unsichtbar gefiltert.
  - Fix: `getContractTreeForCustomer(customerId, includeDeactivated)`
    mit optionalem Flag (Default false = bisheriges Verhalten). Query-
    Param `includeDeactivated=true`, Toggle-Button im Kunden-Vertragstab
    (`Eye`/`EyeOff`). `showDeactivated` ist Teil des Query-Keys →
    frischer Fetch beim Umschalten. Deaktivierte Verträge tragen ihr
    graues `DEACTIVATED`-Badge, bleiben also klar erkennbar.

- [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht**
  - Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
    des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId`
    – wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment
    noch selbst `previousContractId=A`, C sollte auch auf A → Duplicate-
    Entry, das Update brach ab. (2) Der Frontend-`deleteMutation` in
    `ContractDetail` invalidierte die Contract-Listen-Query nicht,
    navigierte nur nach `/contracts` und zeigte den alten Cache.
    Zusammen sah es aus, als wäre die ganze Historie weg – tatsächlich
    stand der Vorgänger noch im Backend.
  - Fix Service: in einer Transaktion erst B löschen (DB-Cascade räumt
    C.previousContractId auf NULL und gibt den A-Slot frei), dann C
    sauber auf A umbiegen. Kein Zwischenzustand mehr sichtbar.
  - Fix Frontend: `queryClient.invalidateQueries(['contracts'])`
    plus `['contract-tree', customerId]` und `['customer', customerId]`
    nach dem Delete. `onError`-Toast ergänzt, damit fehlgeschlagene
    Löschungen nicht mehr still verschwinden.

- [x] **📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event**
  - Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
    `portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum
    generischen Contract-Update-Diff ein dediziertes `UPDATE`-Event
    unter `resourceType: 'ContractPassword'` — landet damit in derselben
    CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
  - Motivation: das Setzen des Flags räumt server-seitig
    `portalUsername`/`portalPasswordEncrypted`/`stressfreiEmailId` auf
    `NULL`. Diese Löschung sensitiver Daten ist jetzt unabhängig vom
    generischen Diff nachweisbar. Rücknahme wird ebenfalls geloggt.
  - `details` enthält alte + neue Flag-Werte plus Bool-Marker, welche
    Anmeldedaten vor dem Opt-out belegt waren (keine Klartext-Leckage).

- [x] **🔕 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out**
  - Dritter Radio-Button in der `Zugangsdaten`-Card des Vertragsformulars:
    „Nicht möglich zu bekommen / nicht benötigt". Wenn gesetzt, unterdrückt
    das Cockpit die Warnung „Portal-Zugangsdaten fehlen" für diesen
    Vertrag (Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten).
  - Neues Feld `Contract.portalCredentialsNotRequired` (Boolean, default
    false) + Migration `20260717100000_contract_portal_credentials_not_required`
    (`ADD COLUMN IF NOT EXISTS`). Bestand bleibt unverändert warnend.
  - Passwort-Spalte im Formular wird bei Opt-out ausgeblendet; alte
    Username-/Passwort-Werte werden beim Umschalten geleert, damit der
    Vertrag nach dem Speichern nicht mit Leichen zurückkommt.

- [x] **🔧 Debug-Hilfe: Fehler bei „PIN/PUK anzeigen" sichtbar machen**
  - Der stumme `alert('PIN/PUK konnte nicht geladen werden')` maskierte
    die eigentliche Ursache. Ersetzt durch Toast mit Backend-Message
    (Vertrag nicht gefunden / Kein Zugriff / Decrypt-Fehler),
    `console.error` mit Stacktrace und einem Info-Toast, falls die SIM
    in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am
    Endpoint – nur Diagnose.

- [x] **🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen**
  - Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet
    `/customers/{id}?tab=stressfrei` in neuem Tab, damit man beim Anlegen
    einer Portal-Wechseladresse nicht durch die Vertragsansicht zurück
    navigieren muss. Sichtbar in beiden Modi.
  - Als R116 vom Pentester geprüft, kein Finding (SPA-Parameter, Backend
    wertet `?tab=` nicht aus; Sub-Route-IDOR-Check auf `canAccessCustomer`
    steht).

- [x] **🔧 Pentest R111 – contract-categories: falscher Permission-String**
  - Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*`
    für Admin trotz vorhandener `contract-categories:*`-Permissions.
    Vermutete Regression aus R110, war aber schon seit dem ersten
    Commit so – die Route verlangte `developer:access`, während der
    Permission-Seed den Admins `contract-categories:*` gibt.
  - Fix: Route auf `contract-categories:create/update/delete`
    umgestellt, konsistent mit den sechs Peer-Katalog-Endpunkten
    (platforms/tariffs/cancellation-periods/…). Kommentar aktualisiert,
    damit die historische Design-Restriktion nicht wieder eingebaut wird.

- [x] **🔒 Pentest R110 – Mass-Assignment-Whitelist auf 7 Update-Endpunkten**
  - MEDIUM-Finding: `PUT /api/stressfrei-emails/:id` und 6 weitere Update-
    Endpunkte (`platform`, `tariff`, `contractCategory`,
    `cancellationPeriod`, `contractDuration`, `email-providers`) reichten
    `req.body` ungefiltert an Prisma – gleiche Bug-Klasse wie M1
    (Settings Mass Assignment). Nachgewiesen war es via
    `provisionError`-Feld ausserhalb des TS-Types.
  - Fix: sieben Whitelists + `pickXxxUpdate()`-Helper in `sanitize.ts`,
    in den jeweiligen Controllern eingehängt. Nur die vom Service-
    Interface deklarierten Felder passieren.
  - EmailProvider: bewusst ohne `stripHtmlFromStrings`, weil das
    Passwörter/API-Keys mit Sonderzeichen mutiliert hätte.

- [x] **🐞 Kündigungsdatum: Cursor sprang beim Tippen aus dem Feld**
  - `<input type=date>` feuerte `onChange` bei jedem Tastendruck; sobald
    z.B. `18.08.0002` ein gültiges Datum ergab, feuerte die PUT-Mutation,
    Server-Antwort re-renderte den Input, Cursor sprang raus, Seite
    scrollte, weiteres Tippen landete im nächsten Feld.
  - Fix: neue `AutosaveDateInput`-Komponente hält den Wert während der
    Eingabe lokal, feuert `onSave` erst bei Blur/Enter. Escape verwirft.
    Angewendet auf beide Kündigungsbestätigungs-Datumsfelder in
    ContractDetail. Andere App-Datumsfelder laufen über react-hook-form
    (batched Submit) und sind nicht betroffen.

- [x] **🐞 IMAP-Fehler: „Command failed" ohne Kontext**
  - `imapflow` wirft bei jedem IMAP-NO/BAD-Response nur
    `new Error('Command failed')` und legt Details in `.responseText`/
    `.responseStatus` ab. Wir haben sie nirgends gelesen → User sah
    im Sync-Toast und im Anhang-Download nur "Command failed" ohne
    Ursache. Realer Fall: abgelaufenes Postfach-Passwort.
  - Fix: zentraler `humanizeImapError()`-Helper in `imapService.ts`.
    Extrahiert responseText/responseStatus, erkennt Auth-Fehler,
    UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare
    Meldung mit Handlungsanweisung zurück. Angewendet auf
    `fetchAttachmentInner`, `downloadAttachment`-Controller und
    `syncEmailsForAccount`.

- [x] **🆕 Bankkarte-Beschreibung in Vertragsansicht + Auswahl**
  - Nach dem Anlegen des `description`-Feldes (Bankkarten-Modal) wird
    die Beschreibung jetzt auch in der Vertragsansicht (Bankkarten-Card)
    und im ContractForm-Select angezeigt. Im Select mit
    Whitespace-Flatten und 60-Zeichen-Cap, damit lange Notizen den
    Select nicht sprengen.

- [x] **🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500**
  - INFO-Finding: `GET/PUT /api/customers/:id/salutation-preference`
    warfen bei nicht-existierendem Customer 500 statt 404.
  - Ursache: `canAccessCustomer` prüft für Staff-User nur Portal-Flag,
    kein Existenz-Check. Service warf `new Error(...)`, Controller-
    Catch mappte generisch auf 500.
  - Fix: neuer Helper `assertCustomerExists()` im Service wirft
    `ApiError(404, ...)`. Die drei Controller-Catches respektieren
    jetzt `ApiError.statusCode`. Auch `set`/`clear` prüfen Existenz
    (sonst käme ein Prisma-P2003 als 500 durch).

- [x] **🔒 Pentest R102 – Interne Vertragsnummer nachziehen**
  - INFO-Finding: `contractNumber` (interne CRM-Nummer) hatte im
    Update-Path keine Whitelist. `<script>alert(1)</script>` →
    `stripHtml` → leerer String → Vertragsnummer silent überschrieben.
  - Fix: `contractNumber` zu `CONTRACT_IDENTIFIER_FIELDS` hinzugefügt –
    bestehende R86-Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) greift
    automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin.
    Legitime `GAS-MPVJ4P10FE8` bleibt OK, alle Angriffs-Payloads → 400.
  - Doku in `SECURITY-HARDENING.md § Runde 102`.

- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
  - Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
    bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
    `fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
    Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
    gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
    aber nicht reproduzieren.
  - Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
    extrahiert, finally-Block schließt File-Descriptor garantiert,
    Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
  - Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
    es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
    R30.13).
  - Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
    Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
    der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
    war's silent.

- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
  - R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
    erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
    Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
    durchgehen.
  - R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
    Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
    bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
  - Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
    läuft **vor** dem `sendEmail`-Aufruf:
    - `attachments` muss Array oder undefined sein
    - max 25 Anhänge
    - jeder: `filename` non-empty String, `content` non-empty Base64-
      String (Regex), optional `contentType` String
    - max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
      kein Buffer.from-Aufruf während der Validierung)
  - Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
    grün inkl. aller R97.1-Pentest-Payloads.

- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
  - Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
    "Datei anhängen":
    - **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
      gruppiert nach `documentType`. Auswahl → Server-Download via
      `fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
      direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
    - **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
      haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
      Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
      Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
      `documentPath` vorhanden ist.
  - Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
    (mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
    `composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
    wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
    übersprungen statt silent weggeschluckt.
  - Helpers (`composeAttachmentHelpers.ts`):
    - `serverFileToAttachment(path, filename)` – fetch via Token-URL
      → Blob → base64 → `EmailAttachment`.
    - `totalAttachmentBytes` – Größen-Check unter Berücksichtigung der
      ~33 % base64-Overhead.
    - `bankCardAttachmentName` / `identityDocAttachmentName` –
      sprechende Dateinamen für den Empfänger.

- [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) härten**
  - R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
    sobald der Wert in Mail-Templates / PDF-Footer landet.
  - R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
    (R87.1-Pattern, dritter Treffer auf demselben Bug).
  - R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
  - Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
    `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
    Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
    Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
    validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
  - Frontend: `maxLength={100}` am Input.
  - **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
    Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone,
    1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
    Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.

- [x] **🔒 Pentest R93 – Leerer String != fehlender Param**
  - R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
    weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
    Pentester-Spec: leerer String ist KEINE gültige Zahl.
  - Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
    (`v === undefined` ist absent; `''`, `'  '`, alles andere muss
    parsen). Required + optional Modes unverändert.
  - Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
    by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).

- [x] **🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten**
  - R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
    auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
    Isolation brach (alle Postfächer sichtbar). Pentester R92: per
    Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
  - Fix: `parsePositiveIntQuery(v, label, res, { required? })`
    ersetzt den alten silent-Helper. Modes:
    - default (optional): fehlend/leer → `undefined` (kein Filter),
      invalid → 400
    - `{ required: true }`: fehlend/leer **oder** invalid → 400
  - Verteilung:
    - Contract-Emails, Contract-Folder-Counts: `{ required: true }`
    - Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
      View ist legitim), invalid → 400
  - Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
    den Vertrags-Queries – kein UX-Bruch.

- [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param**
  - R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der
    Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
    falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
    ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
    statt nur die aus dem gewählten Postfach (kein Cross-Customer-
    Leak — `canAccessContract` greift weiter).
  - Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
    der nur positive Ganzzahlen aus dem Query-String akzeptiert und
    alles andere zu `undefined` macht. Eingesetzt in allen 5
    Endpunkten, die `accountId`/`contractId` aus Query nehmen
    (Contract-Emails, Contract-Folder-Counts, Customer-Emails,
    Trash, Trash-Count) – auch da, wo der Pentester nicht getestet
    hat, weil derselbe Pattern überall stand.

- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
  - Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
    Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
    des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
    Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
  - Backend:
    - `getEmailsForContract` controller akzeptiert jetzt `accountId`-
      Query-Param und reicht ihn als `stressfreiEmailId` an
      `getCachedEmails` weiter (der hat den Filter eh schon implementiert,
      nur niemand hat ihn aufgerufen).
    - `getTrashEmails` (controller + service) akzeptiert `accountId` und
      `contractId` als optionale Filter. Default-Verhalten unverändert,
      wenn keiner gesetzt ist.
    - `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
      bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge
      der Trash-Badge im Vertrag wieder account-global, während die Liste
      contract-scoped ist.
  - Frontend:
    - `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
      nehmen den Filter entgegen.
    - `ContractEmailsSection` reicht `selectedAccountId` in alle drei
      Queries durch und nimmt es in den queryKey mit auf – sonst greift
      der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
      kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
      synchron laufen.
    - `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
      durch (Inbox/Sent waren schon korrekt).

- [x] **🔒 Pentest R89 – Provider-Adressfelder härten**
  - R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
    statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit
    200 OK gespeichert.
  - R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `<script>…</script>`
    reduzierte auf leeren String → `null` in der DB → vorheriger Wert
    silent überschrieben (R87.1-Pattern auf Adress-Feldern).
  - Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
    max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
    Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
    Eingehängt in `stripProviderStrings`.
  - R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester
    bestätigt "kein unmittelbares Risiko", React escaped korrekt,
    sind legitime Bestandteile mehrzeiliger Postadressen.
  - Doku in `SECURITY-HARDENING.md § Runde 89`.

- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
  - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
    `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
    `cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
    (mehrzeilig), Rest `VARCHAR(191)`. Migration
    `20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
  - Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
    Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
    Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
    Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
    als `<textarea rows={3}>` mit `maxLength={500}`.
  - Backend-Validierung: contactEmail/cancellationEmail laufen durch
    `isValidEmail` (Header-Injection-Schutz für Mail-Templates),
    contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
    (kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
    500-Cap.
  - Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
    Felder beim Backup/Restore verloren.

- [x] **🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)**
  - R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
    Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
    `<script>…</script>` reduzierte auf leeren String → `null` in
    der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
  - Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
    umgedreht – `validateContractIdentifier` läuft jetzt direkt
    gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
    ab, was stripHtml normalerweise auffangen würde (Tags,
    Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense-
    in-Depth bleibt, nur ehrlich (400 statt silent-200).
  - Single-Line-Patch in `contract.controller.ts`, Doku in
    `SECURITY-HARDENING.md § Runde 87`.

- [x] **🔒 Pentest R86 – Vertrags-Identifier härten**
  - R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
    Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
  - R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
    (`foo" onerror=…`) überlebte `stripHtml` – kein Risiko in der React-
    UI, aber relevant für PDF/Mail/CSV-Export.
  - Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
    mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
    literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
    Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
  - Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
    fünf Identifier-Felder bei Create/Update. ContractForm bekommt
    `maxLength={100}` als UX-Schicht. Doku in
    `docs/SECURITY-HARDENING.md` § Runde 86.

- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
  - Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
    (`VARCHAR(191) NULL`), Migration
    `20260619100000_contract_order_number_at_sales_platform` mit
    `IF NOT EXISTS`.
  - Im ContractForm direkt **vor** der Kundennummer der
    Vertriebsplattform angeordnet (Wunsch des Users).
    ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
    den anderen beiden Sales-Platform-Feldern.
  - Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
    XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
    mitgezogen, damit das neue Feld die gleichen Garantien wie
    Kunden-/Vertragsnummer bekommt.

- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
  - Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
    Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
    Plesk aber nicht in DB" → schreibt `c` zurück in
    `additionalForwardingEmails` → Diff sagt nichts zu entfernen.
  - Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
    Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
    sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
    explizite Intent (Import schädlich).
  - Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
    mit Default `true`. `setAdditionalForwards` ruft mit
    `false` auf → entfernte Adressen verschwinden jetzt sauber bei
    Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
    alte Bestands-Members).

- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
  - Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
    nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
    akzeptiert ausschließlich `add:` und `del:` – unser `set:` wurde
    von Plesk silent verworfen. Außerdem gibt es keine separate
    `-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
    zeigt, ist genau das, was `-forwarding true` in der CLI setzt
    (doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
    den Phantom-Parameter und triggerte `Unrecognized option`.
  - `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
    Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
    `add:<neu>` in zwei separaten CLI-Calls. Idempotent.
    Case-insensitive – `Bruns.Gerhard` ≡ `bruns.gerhard`.
  - Phantom-`-mailgroup`-Parameter entfernt.
  - Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
    nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.

- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
  - **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
    `assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
    oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
    DB gewandert. Fix: jeder importierte Member läuft durch
    `assertValidForwardingEmail`; ungültige werden silent gedroppt
    und auf `console.debug`-Level geloggt.
  - **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
    Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
    sie beim Auto-Import in die DB-Liste gerutscht → nach dem
    Umschalten auf Forwarding Mail-Loop. Fix:
    `seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
    der Import-Schleife.
  - **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
    `console.log` auf Default-Level).
  - Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
    importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
    abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
    abgelehnt, Customer-Stamm-Mail + Default deduped.

- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
  - Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
    Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
    Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
    `Group member(s):`) und **Forwarding** (`Forward request:`). Unser
    Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
    → unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
    Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
    wurden (Forwarding-Modus von Anfang an).
  - `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
    `forwardingActive` + `forwardingTargets` erweitert.
  - `pleskProvider.emailExists` parst alle vier Felder aus dem
    `--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
    `Forward request: ...`).
  - `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
    `-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
    und nur noch Forwarding aktiv ist.
  - `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
    Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
    `additionalForwardingEmails`-Liste **importiert** (canonical-Key-
    Dedup). Verlustfrei – kein bestehender Empfänger fällt beim
    Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
    aber der eigentliche Sync läuft trotzdem.

- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
  - Bug: User konnte die Stressfrei-Adresse selbst (z.B.
    `max.mustermann@stressfrei-wechseln.net`) als zusätzliches
    Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk
    leitet auf sich selbst um → Mail-Loop.
  - Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
    `meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
    gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
    `ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
    – der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
  - Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
    Validierung im Sub-Modal mit identischem `canonicalize`-Helper
    (Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
    auch der Create-Modus (vor dem Persistieren) den Check fahren
    kann. Spart einen Roundtrip + zeigt sofort eine sprechende
    Meldung „… zeigt auf die Adresse selbst – Mail-Loop".

- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
  - `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
    den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
    auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
    `4.0` und Exp-Notation `4e1`.
  - Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
    Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen,
    Whitespace, Hex.
  - Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
    `0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
    `100`, `9999999` durchgewunken.

- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
  - Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
    anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
    hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
  - Service: Vor `prisma.create` jetzt `findFirst` auf
    `(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
    Unterschiedliche Meldung für aktive vs. inaktive Duplikate
    (Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
  - `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
  - Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
    (vorher pauschal 400) → 409 kommt sauber durch.
  - Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
    nur ins Leere lief.

- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
  - **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
    `corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
    `example`, `intranet`, `localdomain`, `arpa`) werden in
    `assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
    Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
  - **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
    Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
    lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
    dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
    und im sync-Pfad gegen `config.defaultForwardEmail`.
  - **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
    fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
    Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
    umgestellt (auch über das gemeldete Finding hinaus).
  - **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
    Provider-Sync-Fehler zurück, damit DB und Plesk nicht
    auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
    wird vor dem Update gemerkt und bei Fail wieder eingespielt.
  - Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
    TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
    Strip funktioniert (auch mit Multi-Plus + Casing).

- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
  - Im „Adresse hinzufügen"-Modal erscheint der „Weitere
    Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
    anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
    läuft direkt nach `createEmail` mit der vollen Liste.
  - Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
    Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
    → lokaler State (Create). Counter-Badge am Button zeigt die
    Anzahl Adressen.

- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
  - Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
    JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
    mit `IF NOT EXISTS`.
  - `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
    in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
    `customer.email` und `config.defaultForwardEmail`).
  - Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
    mit Body `{ emails: string[] }` – ersetzt die Liste und syncht
    direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
    Regex, Audit-Log.
  - Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
    (Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
    mit Liste + Add/Remove. Jede Änderung geht sofort live.

- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
  - Vier identische Vorkommen desselben Anti-Patterns wie beim
    AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
    im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
    Tastendruck setzte den State zurück.
  - Fix in allen vier Modals (MeterModal, BankCardModal,
    IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
    `[<entity>?.id]`-Dependency umgezogen.

- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
  - Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
    haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
    auch ohne Re-Encode auf 5-10 MB pro Bild.
  - Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
    runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
    Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
    persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
    PDF-Embed laufen alle auf dem skalierten Bild.
  - Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).

- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
  - `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
    Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
    `ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
    `?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
  - `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
    damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.

- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
  - Zwei neue optionale Felder
    `Contract.customerNumberAtSalesPlatform` +
    `contractNumberAtSalesPlatform`, Migration
    `20260603150000_contract_sales_platform_numbers` mit
    `IF NOT EXISTS`.
  - Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
    Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
    mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
    (CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
  - Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
    und Audit-Label-Mapping ist gleich mit drin – wurde bisher
    nicht in VVL-Folgeverträge kopiert.

- [x] **🆕 Email-Links öffnen im neuen Tab**
  - In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
    auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
    verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
    in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.

- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
  - Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
    Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
    Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
    enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
  - Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
    PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
    von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
    Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
  - Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
    durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
    blockiert, clean PDF weiterhin OK.

- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
  - `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
    `formData.street !== address.street`. Jeder Tastendruck löste neu
    aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
    möglich.
  - Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
    Re-Init nur beim Wechsel/Open, nicht bei jedem Render.

- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
  - Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
    (auch mit Original-Bytes-Optimierung aus 431792e). Limits in
    `upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
    25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
  - Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".

- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
  - Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft –
    eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
    statt entweder/oder.
  - Schema: `SimCard.isEsim Boolean @default(false)`, Migration
    `20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
  - Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
    (Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
  - UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
    Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.

- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
  - Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
    Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
    auf (Quality 100 % heißt nicht „identisch zum Original", sondern
    „möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes
    Smartphone-Foto wird so künstlich 4-8× größer).
  - **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
    Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip,
    keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
    JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
  - **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
    `1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
    aber 50-70 % kleiner.
  - Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
    16-30 MB), 2 gedrehte ≈ 5-8 MB.

- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
  - Globaler Error-Handler in `index.ts:461` matcht
    `/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
    Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
    bei 500 + Error-Log-Spam.
  - Fix: 1 Zeile in `contract.routes.ts` – `… WebP-Dateien sind
    erlaubt` macht den Regex glücklich. Andere Routes
    (`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
    `factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
    schon „sind erlaubt".

- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
  - `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
    `image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
    Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
    mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
    `.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
    Guard), aber inkonsistent.
  - Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
    zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.

- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
  - `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
    PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
    Uploads waren ungeprüft – kompensiert durch Download-Layer
    (`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
    inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
    Pfad", aber inkonsistent zu `upload.routes.ts`.
  - **Refactor:** `detectType` + `validateUploadedFile` aus
    `upload.routes.ts` in neue Middleware
    `middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
    of Truth). Beide Routes nutzen jetzt denselben Helper.
  - **contract.routes.ts:** `validateUploadedFile` ersetzt das
    schlankere `scanUploadedPdfIfPresent` – jetzt greift Magic-Byte +
    canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
    anderen Upload-Pfaden.
  - **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
    da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
    bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.

- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
  - **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
    nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
    Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
    den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox)
    ignorieren JS, Adobe Acrobat aber nicht.
  - Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
    String-Scan auf die fünf Action-Pattern (case-sensitive nach
    PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
  - Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
    - `requireSafeUploadedPdf` – Datei MUSS PDF sein, sonst 415.
    - `scanUploadedPdfIfPresent` – durchwinkt JPG/PNG, scannt nur PDFs.
  - Eingehängt:
    - `upload.routes.ts` (Magic-Byte-Validator erweitert)
    - `gdpr.routes.ts` Vollmacht-Upload
    - `pdfTemplate.routes.ts` Template-Upload
    - `contract.routes.ts` Vertragsdokumente
    - `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
      saveAttachmentTo, saveAttachmentAsInvoice,
      saveAttachmentAsContractDocument)
  - **Inline-Vorschau bleibt erhalten** – das war die explizite
    Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
    Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
    umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
    (PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
  - Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
    NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
  - **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
    Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
    UX-Schutz, kein Security-Bug (Self-DoS only).

- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
  - Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
    keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
    Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
    per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
    Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
    A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
  - `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
    Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
    Handelsregister, Kündigungsschreiben + -Bestätigung +
    deren Optionen) bekommt automatisch einen sekundären
    "JPGs → PDF"-Button, wenn `accept` PDF einschließt.
  - Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
    (ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
    Rechnungen (InvoicesSection).
  - PdfTemplates **bewusst ausgenommen** – braucht echte
    AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.

- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
  - `customerEmailLabel` existierte im Backend (Schema +
    Update-Logik + Public-Endpoint), war im UI aber nicht
    erreichbar – das Label kam ausschließlich aus
    `deriveLabelFromDomain`. Jetzt neuer optionaler Input
    „Bezeichnung im UI" unter dem Domain-Block in
    Einstellungen → E-Mail-Provider. Leer = Auto-Ableitung,
    befüllt = überschreibt das Tab-Label und alle anderen
    Stellen, die `customerEmailLabel` nutzen (max 60 Zeichen).

- [x] **🆕 SIM-Karten: Feld „Kartennutzer"**
  - Bei Firmen- und Familienverträgen weicht der
    Vertragsinhaber (Firma/Eltern) vom tatsächlichen Nutzer
    (Mitarbeiter/Kind) ab. Neuer optionaler `SimCard.cardUser`
    (String), Migration `20260601100000_sim_card_user` mit
    `IF NOT EXISTS`.
  - ContractForm: eigene Zeile „Kartennutzer" oberhalb der
    technischen SIM-Felder mit erklärendem Placeholder.
  - ContractDetail: zeigt „Nutzer: \<Name\>" neben den
    Hauptkarte/Multisim-Badges (nur wenn gefüllt).

- [x] **🆕 Vorgängervertrag-Modal: Kundennr./Vertragsnr. sichtbar machen**
  - Die „Anbieter & Tarif"-Card im ContractDetailModal war
    nur sichtbar, wenn Provider oder Tarif gesetzt waren –
    Bei Entwürfen ohne Anbieter wurden so auch
    `customerNumberAtProvider` + `contractNumberAtProvider`
    versteckt, obwohl sie gepflegt sein können. Fix:
    Sichtbarkeitsbedingung um die beiden Felder erweitert.

- [x] **🆕 ContractEmails-Card: Quicklinks auch ohne Postfach**
  - „Postfach öffnen" + „Stressfrei wechseln Adressen" waren
    nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen
    `cardTitle`-Variable extrahiert und auch im
    „Kein-Mailbox"-State sichtbar.

- [x] **🆕 Vertrags-Forms: Mini-Links zu Stammdaten**
  - In ContractEmailsSection neben „Postfach öffnen" jetzt
    zusätzlich „Stressfrei wechseln Adressen" → Kundenakte-Tab.
  - In ContractForm kleine ExternalLink-Icons neben Select-Labels:
    Lieferadresse/Rechnungsadresse → `?tab=addresses`,
    Bankkarte → `?tab=bankcards`, Ausweis → `?tab=documents`,
    Anbieter + Tarif → `/settings/providers`,
    Vertriebsplattform → `/settings/platforms`.
  - Select-Komponente nimmt jetzt `ReactNode` als label
    (statt nur string), rückwärtskompatibel.
  - In ContractDetail (Strom/Gas Card-Header) zusätzlich
    permanenter „Zähler verwalten"-Link in neuem Tab.

- [x] **🆕 Vertragshistorie: Vertragsnummern als Link**
  - Erkennt Vertragsnummern (`PREFIX-RANDOM`) in `title` und
    `description` via Regex, löst sie gegen
    `previousContract` + `followUpContract` des aktuellen
    Vertrags auf und rendert sie als Link in neuem Tab.
    Nicht aufgelöste Nummern bleiben als Text.

- [x] **🆕 UI-Bug-Fix: „Wurde sondergekündigt?"-Checkbox-Label**
  - Label-Klasse war `flex` → Block-Layout über volle
    `col-span-2`-Breite. Klick rechts neben dem Text
    triggerte ungewollt die Checkbox. Fix: `inline-flex`.

- [x] **🆕 „Zähler verwalten"-Link im Folgezähler-Form**
  - In der SuccessorMeterForm (Vertragsansicht) im Header
    rechts neben „Folgezähler hinzufügen (Zählerwechsel)"
    ein Link in neuem Tab zur Zähler-Verwaltung des Kunden,
    damit man bei „Kein passender Zähler verfügbar" direkt
    rüberspringen kann.

- [x] **🆕 Folgezähler-Forms: Checkbox „Alten Zähler deaktivieren"**
  - Beide Folgezähler-Forms (Kundenakte MeterModal +
    Vertragsansicht SuccessorMeterForm) bekommen eine
    Checkbox, die standardmäßig angehakt ist. Beim Speichern
    wird der Vorgänger automatisch auf `isActive=false`
    gesetzt – ein-klick-fähiger Zählerwechsel.
  - Backend: `createMeter` mit `successorOf` und
    `addSuccessorMeter` akzeptieren `deactivatePredecessor`
    (Default true).

- [x] **🆕 Kundenakte → Zähler: Verträge-Aufklappliste + Filter**
  - Pro Zähler ein „Verträge (N)"-Aufklapp, listet alle
    Verträge auf, die diesen Zähler nutzen – als Hauptzähler
    (`energyDetails.meterId`) oder über Folgezähler-Kette
    (`ContractMeter`). Dedupliziert. Jeder Eintrag ist Link
    in neuen Tab mit Vertragsnummer, Anbieter, Status-Badge.
  - Neue Checkbox „Zähler ohne Verträge anzeigen" neben
    „Inaktive anzeigen". Filtert die Liste auf Orphans
    (Zähler ohne Vertragszuordnung):
    | Inaktive | ohne Verträge | Ergebnis |
    |----------|---------------|----------|
    | ☐ | ☐ | nur aktive Zähler (Default) |
    | ☑ | ☐ | alle Zähler |
    | ☐ | ☑ | aktive Zähler ohne Vertrag |
    | ☑ | ☑ | alle Zähler ohne Vertrag |

- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
  - Folgeverträge ohne ContractMeter-Eintrag (alte Daten oder
    pure Single-Meter-Verträge) bekamen den Button nie zu
    Gesicht. Fix: Button wird jetzt aus dem if/else gerendert,
    sobald entweder ein Single-Meter oder ContractMeter-
    Einträge vorhanden sind. Im Backend wird der bisherige
    `energyDetails.meterId` bei Single-Meter-Verträgen
    automatisch als ContractMeter (position 0,
    `removedAt` = Wechseldatum) backfillt, damit der alte
    Zähler in der Historie bleibt.

- [x] **🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen**
  - Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
    sind, hängen über `ContractMeter` auch Folgezähler dran,
    die nach Vertragsende installiert wurden. Die Berechnung
    nahm `cm.installedAt..cm.removedAt` 1:1 ohne Clamp gegen
    `contract.startDate/endDate` – damit flossen Zählerstände
    aus der Folgevertrags-Phase in den Verbrauch des
    Vorvertrags ein. Fix:
    `meterStart = max(installedAt, contractStart)`,
    `meterEnd = min(removedAt, contractEnd)`,
    Zähler komplett außerhalb der Laufzeit werden übersprungen.

- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
  - Strom/Gas-Card zeigt jetzt neben der Zählernummer ein
    rotes „Inaktiv"-Badge (falls deaktiviert) und darunter
    eine kleine Zeile „Standort: …" (falls gepflegt).

- [x] **🛡️ Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker**
  - 43.6: `ContractHistoryEntry.title` + `.description` waren
    auf beiden Pfaden ungestrippt – Admin konnte HTML/Script-
    Tags einschreiben, Portal-User las sie roh zurück. Fix:
    `stripHtml()` auf Create + Update (Write-Pfad) und
    `sanitizeEntry()` im List + Get (Read-Pfad).
  - 43.5: `stripHtml` ersetzt `javascript:` → `blocked:` –
    sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen.
    Neuer `stripForDisplay`-Wrapper entfernt den Marker
    zusätzlich in den Display-Feldern.

- [x] **🛡️ Pentest 42.5 (MEDIUM): priceFirst12Months XSS**
  - Drei Preisfelder sind im Schema `String?` (freitextliche
    Tarifangaben). `sanitizeContract` strippte sie auf dem
    Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1
    raus. Fix: `priceFirst12Months`, `priceFrom13Months`,
    `priceAfter24Months` in `CONTRACT_DISPLAY_STRING_FIELDS`
    aufgenommen.

- [x] **🛡️ Anzeige-Defense: HTML in display-relevanten Strings strippen**
  - `sanitizeContract` und `sanitizeCustomer` strippen jetzt
    zusätzlich HTML in den definierten Display-Feldern
    (`providerName`, `tariffName`, `customerNumberAtProvider`,
    `firstName`, `lastName`, `companyName`, etc.). Wirkt auch
    auf nested `previousContract` + `energyDetails`. Damit
    sehen Pentester-Payloads (`<script>`, `<img onerror>`)
    in der Vertragsliste nicht mehr als hässlicher Klartext
    aus, sondern verschwinden komplett.

- [x] **🆕 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag**
  - **ContractForm** (Strom/Gas): Wenn ein `previousContractId`
    gesetzt ist, wird der Vorvertrag samt Readings nachgeladen und
    der Verbrauch clientseitig über
    `calculateMultiMeterConsumption` / `calculateConsumption`
    berechnet. Unter dem Jahresverbrauch-Feld erscheint
    `Vorvertrag: 1.698 kWh (hochgerechnet) [Übernehmen]` mit
    Ein-Klick-Button, der den Wert ins Feld kopiert. Bei Gas
    erscheint der Hinweis sowohl unter „Jahresverbrauch (m³)"
    (mit m³-Wert) als auch unter „Jahresverbrauch (kWh)".
  - **ContractDetail** (Strom/Gas): Wenn `annualConsumption` leer
    ist und ein berechenbarer Vorvertrag existiert, wird die
    Jahresverbrauch-Zelle stattdessen mit `~1.698 kWh` in blau
    angezeigt, darunter klein „geschätzt aus Vorvertrag
    (hochgerechnet)". Verschwindet automatisch, sobald der Wert
    im Vertrag eingetragen ist.
  - Funktioniert nur bei Verträgen mit explizitem `previousContract`
    (Folgevertrag-Kette). Ohne Vorvertrag oder ohne genügend
    Zählerstände kommt kein Hinweis.

- [x] **🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein**
  - Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
    `ContractMeter.finalReading` gespeichert, aber nirgends gelesen
    – weder in der UI noch in `calculateMultiMeterConsumption`.
  - Neuer Helper `recordPredecessorFinalReading(meterId, switchAt, value)`
    in customer.service.ts: legt am Wechseldatum einen regulären
    `MeterReading`-Eintrag für den Vorgänger an (Notes:
    „Endstand bei Zählerwechsel"). Idempotent: existiert am
    Wechseltag schon ein Reading, wird nichts geschrieben.
    Validierung (monoton-steigend) wird vorab durchgeführt –
    Konflikt führt zu sprechender 400-Fehlermeldung, ohne
    halb-geschriebene Zustände zu hinterlassen.
  - Wird aus beiden Pfaden aufgerufen: `addSuccessorMeter` im
    contract.controller (Vertragsansicht → „Folgezähler hinzufügen")
    und `createMeter` mit `successorOf` im customer.service
    (Kundenakte → „Als Folgezähler deklarieren").
  - Folge: Der Endstand erscheint jetzt in der Zählerstände-Liste
    des Vorgänger-Zählers und fließt über
    `calculateMultiMeterConsumption` automatisch in den Verbrauch
    (Zeitraum bis `removedAt` ist inklusive).
  - UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal)
    erklären den neuen Effekt.

- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
  - Bisher nur sichtbar im Multi-Meter-Zweig (`contractMeters.length > 0`)
    – Folgeverträge ohne ContractMeter-Eintrag konnten so keinen
    Folgezähler bekommen.
  - Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald
    entweder ein Single-Meter (`energyDetails.meter`) oder
    ContractMeter-Einträge vorhanden sind.
  - Im Backend `addSuccessorMeter`: bei Single-Meter-Verträgen wird
    der bisherige `energyDetails.meterId` automatisch als
    ContractMeter (position 0, `removedAt` = Wechseldatum) backfillt,
    damit der alte Zähler nicht aus der Vertragshistorie verschwindet.

- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)**
  - **Backend**: Neues Feld `Meter.predecessorMeterId`
    (Self-Relation, `ON DELETE SET NULL`). Migration
    `20260530140000_meter_predecessor` mit `IF NOT EXISTS`.
    `createMeter` akzeptiert optional `successorOf: { predecessorMeterId,
    installedAt?, finalReadingPrevious? }`. Wenn gesetzt: Vorgänger
    wird validiert (gleicher Kunde + gleicher Typ), und für alle
    Verträge, die den Vorgänger als aktuellen Zähler nutzen, wird
    der ContractMeter-Eintrag analog zu `addSuccessorMeter`
    propagiert (vorhandener ContractMeter wird `removedAt` +
    `finalReading` gesetzt; neuer ContractMeter wird mit nächster
    Position + `installedAt` angelegt; `energyDetails.meterId`
    auf den Neuzähler aktualisiert). Idempotent gegen Doppel-Klick.
  - **MeterModal** (Kundenakte → Zähler): Bei Neuanlage neue
    Checkbox „Diesen Zähler als Folgezähler deklarieren". Wenn
    aktiv: Dropdown Vorgänger-Zähler (alle Zähler des Kunden,
    inkl. inaktive – mit Suffix), Wechseldatum (default heute),
    Endstand alter Zähler (optional). Bei Vorgänger-Auswahl werden
    Typ, Tarifmodell und Adresse vom Vorgänger übernommen und
    disabled. Info-Banner: „Alle Verträge mit dem alten Zähler
    werden automatisch umgestellt".
  - Audit-Log: „Zähler angelegt als Folgezähler von X für Kunde #N".

- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
  - In den Strom/Gas-Details neben der Zählernummer zusätzlich ein
    rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls
    hinterlegt.

- [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter**
  - **Backend**: Neues Feld `Meter.addressId` (optional FK auf
    `Address`, `ON DELETE SET NULL`). Migration
    `20260530100000_meter_address` mit `IF NOT EXISTS`. Service
    erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden
    gehören und Typ `DELIVERY_RESIDENCE` haben.
  - **MeterModal** (Kundenakte → Zähler): Pflicht-Dropdown
    "Lieferadresse" über allen Feldern. Button "Zähler hinzufügen"
    ist disabled, wenn keine Lieferadresse existiert – mit gelbem
    Hinweis-Banner. Bestandszähler ohne Adresse zeigen im
    MetersTab "Lieferadresse: nicht zugeordnet – bitte über
    Bearbeiten nachpflegen" in gelb.
  - **ContractForm** (Strom/Gas): Zähler-Dropdown filtert jetzt
    auf die im Vertrag gewählte Lieferadresse. Deaktivierte
    Zähler bleiben sichtbar (Label-Suffix `(deaktiviert)`); wenn
    sie ausgewählt werden, kommt ein Toast: *„Deaktivierter
    Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen
    Altvertrag?"*. Platzhalter wechselt zwischen "Erst
    Lieferadresse wählen…" / "Kein Zähler für diese Adresse
    vorhanden" / "Zähler wählen…".
  - Audit-Log loggt Adress-Änderung am Zähler als Feld
    "Lieferadresse".

- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
  - Zwei neue Log-Panels auf der DB-Backup-Seite: links
    "Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
    Eintrag zeigt ✓/✗-Status, Summary, Timestamp und User. Klick
    öffnet ein Modal mit dem vollständigen Verlauf (alle
    `console.log/error/warn/info`-Zeilen werden während der
    Operation in einen Puffer mitgefangen).
  - Persistiert in neuer Tabelle `BackupLog`
    (Migration `20260519100000_backup_log` mit `IF NOT EXISTS`).
    Limit 1 MB pro `fullLog`, Auto-Refresh alle 5s.
  - Endpoints (settings:update):
    `GET /api/settings/backup-logs?operation=CREATE|RESTORE`,
    `GET /api/settings/backup-logs/:id`.
  - **Bonus**: Das neue Log hat sofort einen alten Bug aufgedeckt –
    `EBUSY: rmdir '/app/uploads'` beim Restore. Ursache: das
    Backup-Service rief `deleteDirectory(UPLOADS_DIR)` mit dem
    finalen `rmdirSync`, aber `/app/uploads` ist ein Bind-Mount,
    den Linux nicht aushängen lässt. Fix: neuer Helper
    `emptyDirectory()` löscht nur die Inhalte, das Verzeichnis
    selbst bleibt stehen.
  - **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
    wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.

- [x] **🛡️ Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung für AppSettings**
  - Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
    API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`,
    `ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor
    in den an Kunden verschickten Portal-Mails.
  - Neuer `validateSettingValue(key, value)` in appSetting.service.ts
    mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host,
    `isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte
    (deadline*/documentExpiry*) → positive Integer. Bool-Settings
    → strict `true`/`false`. monitoringAlertEmail → RFC-5322-light.
  - Controller (updateSetting + updateSettings) ruft Validator nach
    der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger
    Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert
    wird (kein halb-committed-State bei einem ungültigen Eintrag).
  - **Live-verifiziert** auf dev:
    - `/evil/path` → 400 "muss absolute http(s)-URL sein"
    - `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema)
    - `ftp://evil.com` / `data:text/html` → 400
    - `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt)
    - `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
    - `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt)
    - `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc`
      (nur trailing slash; doppelte slashes mittendrin bleiben)

- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
  - Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
    `sanitizeContractBody`-Funktion lief auch über `portalPassword`.
    Passwörter mit HTML-Pattern (`Pass<TAG>word!` → `Password!`) oder
    URI-Schema-Prefix (`data:secret` → `blocked:secret`) wurden
    irreparabel zerstört.
  - Fix: `PASSTHROUGH_KEYS = {'portalPassword', 'password'}` – beim
    Walk werden String-Werte unter diesen Keys NICHT durch
    `stripHtml` geschickt. PW wird sowieso `encrypt()`-verschlüsselt
    persistiert und niemals als HTML ausgegeben → kein XSS-Risk.
  - Live-verifiziert: PW `MyP@ss<word>123!&data:foo` → byte-genau im
    GET-Decrypt-Endpoint zurück. `providerName: <script>…` → weiter
    auf `EvilProvider` gestrippt. `portalUsername: u<test>` → weiter
    auf `u` gestrippt.

- [x] **🛡️ Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)**
  - **31.1 Stored XSS in Vertragsfeldern**: `providerName`, `tariffName`,
    `priceFirst12Months`, `priceFrom13Months`, `priceAfter24Months`
    nahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurück.
    Fix: rekursiver `sanitizeContractBody()` (Walk-and-Strip) im
    contract.controller wird auf `req.body` von POST + PUT
    angewandt. Nutzt das bestehende `stripHtml()` aus utils/sanitize,
    inkl. URI-Schema-Block + Entity-Decode. Verträge enthalten keine
    legitimen HTML-Felder (Editor-HTML lebt in AppSettings), daher
    Strip ohne Risiko. Audit-Vergleich nutzt jetzt die sanitisierte
    Version, sonst Audit ↔ DB-Drift.
  - **31.2 IDOR auf `GET /customers/:id/stressfrei-emails`** (und 4
    weiteren Routes mit `requireCustomerAccess`): das Middleware
    short-circuitete auf `customers:read` – aber Portal-User haben
    diese Perm im JWT (für eigene Daten). Damit kam Portal-Kunde 1
    an IMAP-Konten/Adressen/Bank-Cards/Documents/Meters von
    Kunde 3. Fix in `middleware/auth.ts:requireCustomerAccess`:
    erst `isCustomerPortal`-Check (eigene + vertretene IDs), DANN
    erst Perm-Check für Mitarbeiter. Damit sind alle 6 Routes
    mit einem Middleware-Patch dicht. Defense-in-Depth: in
    `stressfreiEmail.controller.getEmailsByCustomer` zusätzlich
    `canAccessCustomer`-Call analog zum POST-Handler.
  - **Infos** (keine Code-Änderung):
    - `type:"STROM"` ist deprecated – richtige Enum ist `ELECTRICITY`.
    - HSTS auf Staging fehlt: HSTS macht der nginx-Reverse-Proxy,
      Backend setzt's bewusst nicht (Doppel-Header-Vermeidung).
      Auf Staging muss der Proxy-Op das HSTS-Header-Add aktivieren.
    - Portal-Login-Rate-Limit 5 vs 10: Env-Drift, identische Codebase.
  - **Live-verifiziert** auf dev:
    - Portal-User 1 vs Customer 3: alle 6 Routes 403
      (`/customers/3`, `.../addresses`, `.../bank-cards`,
      `.../documents`, `.../meters`, `.../stressfrei-emails`).
    - XSS-Payloads `<script>`, `<svg/onload>`, `<img onerror>`,
      `javascript:`, `&#60;script&#62;` in 5 Vertragsfeldern →
      DB-Werte bereinigt (`EvilProvider`, `blocked:alert(4) 35€` etc.).

- [x] **🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input**
  - **Info-Icon neben Kundennamen** öffnet ein Modal mit den
    wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
    Gründungsdatum, primäre Adresse, Telefon, Mobil, E-Mail,
    Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat
    einen Copy-Button (bestehende `CopyableValue`-Komponente).
    Neue Komponente: `CustomerInfoModal.tsx`. Lazy-Fetch via
    `customerApi.getById`, staleTime 30s.
  - **Cent/Euro-Doppel-Input** für Arbeitspreise (Strom + Gas):
    Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional
    verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct
    aktualisiert € (÷100). Im Backend wird unverändert nur der
    Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch-
    Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000…".
    Greift für `unitPrice` und (bei DUAL-Zählern) `unitPriceNt`.

- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)**
  - Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`.
    Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus`
    (Neukunden), die zusammen den Gesamtbonus ergeben.
  - **Migration** `20260524100000_split_energy_bonus`:
    `ADD COLUMN IF NOT EXISTS instantBonus`, `... newCustomerBonus`,
    `UPDATE` kopiert bestehendes `bonus` → `instantBonus`
    (Annahme: Bestandsverträge hatten primär Sofort-Bonus), dann
    `DROP COLUMN IF EXISTS bonus`. Idempotent.
  - **Form** (`ContractForm.tsx`): zwei Input-Felder „Sofort-Bonus (€)"
    + „Neukunden-Bonus (€)" statt einem.
  - **Detail-Ansicht**: zeigt beide Felder einzeln und einen
    aggregierten „Gesamtbonus" (fett) darunter.
  - **Kostenvorschau**: listet Sofort + Neukunden einzeln auf
    (jeweils grün), dann „Gesamtbonus" (mittel grün, fett), danach
    Effektive Jahreskosten.
  - **Cost-Calc** (`energyCalculations.ts`): `calculateCosts()` nimmt
    jetzt beide Bonus-Werte; `CostCalculation` liefert `instantBonus`,
    `newCustomerBonus` + `totalBonus`.
  - **PDF-Template-Variablen**: drei neue Placeholder
    `energyDetails.instantBonus`, `.newCustomerBonus`, `.totalBonus`
    (alter `.bonus`-Placeholder entfernt).
  - **Audit-Log** unterscheidet jetzt beide Felder.
  - **Live-verifiziert** auf dev: PUT mit beiden Feldern → DB hat
    `instantBonus=75, newCustomerBonus=125`, GET liefert beide
    Werte zurück.

- [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)**
  - **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download`
    lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer
    nur den client-gemeldeten MIME prüft, konnte eine als
    `application/pdf` deklarierte `.html`-Datei auf Disk landen –
    Express bestimmt beim Senden den Content-Type aus der Extension
    (`.html` → `text/html`) und der Browser hätte gerendert. Stored
    XSS für eingeloggte Empfänger. Fix: `Content-Disposition:
    attachment; filename=<safe>` + bestehendes `X-Content-Type-
    Options: nosniff`. Browser lädt jetzt herunter statt zu rendern,
    selbst wenn der Type stimmt. Filename wird auf
    `[A-Za-z0-9._-]` gesäubert.
  - **30.14 SSRF Private-IP-Block opt-in** (INFO): Neuer Env-Flag
    `SSRF_BLOCK_PRIVATE_IPS=true` erweitert die SSRF-Block-Liste auf
    127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1,
    fc00::/7 + IPv4-mapped Varianten + Hostnamen "localhost"/
    "ip6-localhost". Default off, damit On-Prem-Installationen
    (Plesk/Dovecot auf 127.0.0.1) nicht brechen. Cloud-Deployments
    setzen den Flag.
  - **Live-verifiziert** auf dev:
    - Upload + Download: Header zeigt
      `Content-Disposition: attachment; filename="…"` +
      `X-Content-Type-Options: nosniff`
    - Default-ssrfGuard: 127.0.0.1 / 10.x / 192.168.x / localhost → false
      (durchgelassen für on-prem); 169.254.169.254 → true (Cloud-
      Metadata weiter geblockt)
    - Mit `SSRF_BLOCK_PRIVATE_IPS=true`: alle privaten Ranges → true;
      8.8.8.8 (legit public) → false

- [x] **🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)**
  - **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig –
    `blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst
    NICHT geblockt: `http(s):`, `mailto:`, `tel:` (legitime URLs in
    Notizfeldern).
  - **29.1 Cyrillic-Homoglyph**: `jаvascript:` mit kyrillischem
    `а` (U+0430) lief vorher durch. `HOMOGLYPH_TO_ASCII`-Map mit den
    13 üblichen Spoofing-Buchstaben (а→a, е→e, о→o, …) wird VOR dem
    Scheme-Strip angewendet. Legitime Buchstaben außerhalb der Map
    bleiben unangetastet.
  - **29.2 Percent-Encoding**: `java%73cript:` umging die Regex.
    `percentDecode()` läuft jetzt iterativ bis zu 5 Runden (fängt
    auch `java%2573cript:` ab, das zuerst zu `java%73cript:` und
    dann zu `javascript:` wird).
  - **29.3 Zero-Width-Joiner**: `j​av​ascript:` mit U+200B/200C/200D
    etc. wird durch `ZERO_WIDTH_CHARS`-Regex entfernt, bevor irgendein
    Match läuft.
  - **28.3 Partial – PDF-Validierung tiefer**: nicht nur Magic-Bytes,
    sondern auch (1) `%%EOF`-Marker in den letzten 1 KB und (2)
    Pattern-Scan der ersten 4 KB auf `#!/`, `<script`, `<?php`, `<%`,
    `MZ ` (PE-Header). Dadurch wird `%PDF-1.4\n#!/bin/bash` jetzt
    sauber abgelehnt. Voll-PDF-Parsing wäre überzogen – die simplen
    Spoofs sind weg.
  - **29.4 Email-Format-Validator**: neuer `isValidEmail()` in
    sanitize.ts lehnt Whitespace/Newlines/Tabs (= SMTP-Header-
    Injection-Vektor wie `test@x.de\nBcc:attacker@…`) sowie Format-
    Müll wie `notanemail` ab. Verdrahtet in
    `createCustomer`/`updateCustomer`/`updatePortalSettings`/
    `createUser`/`updateUser`. Liefert 400.
  - **29.5 GET /api/providers/email 500 → 404**: `parseInt("email")`
    war `NaN`, Prisma-Query crashte. Controller validiert jetzt
    `Number.isFinite(id) && id ≥ 1` und liefert 404 ("Anbieter nicht
    gefunden"). Numerische IDs funktionieren weiter.
  - **Live-verifiziert** auf dev:
    - blob:/about:/ws:/wss:/ldap:/dict: alle → `blocked:`
    - `jаvascript:alert(1)` (cyr) → `blocked:alert(1)`
    - `java%73cript:alert(1)` → `blocked:alert(1)`
    - `java%2573cript:alert(1)` → `blocked:alert(1)`
    - ZWJ-Variante → `blocked:alert(1)`
    - `%PDF-1.4\n#!/bin/bash` → 400 + Datei gelöscht
    - PDF ohne `%%EOF` → 400
    - Echtes PDF mit `%%EOF` → 200
    - `email: "notanemail"` → 400
    - `email: "test@x.de\nBcc:..."` → 400
    - `GET /providers/email` → 404 (war 500)
    - `GET /providers/<valid_id>` → 200

- [x] **🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)**
  - **28.1 URI-Schema unvollständig**: `DANGEROUS_URI_SCHEMES`-Regex
    erweitert um `file:` und `ftp:`. `ftp://evil.com/x.js` und
    `file:///etc/passwd` in companyName werden jetzt zu
    `blocked://...` bzw. `blocked:///...` neutralisiert.
  - **28.2 HTML-Entity-Decoding-Bypass**: `stripHtml` lief vorher
    direkt über den Roh-String, sodass `&#106;avascript:`,
    `&#x3C;script&#x3E;` und `&lt;script&gt;` an der Regex
    vorbeischlüpften. Neuer `decodeHtmlEntities()` läuft VOR dem
    Strip und dekodiert numerische (`&#NN;`/`&#xHH;`) + gängige
    Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale
    Tag- und URI-Säuberung wieder.
  - **28.3 Vollmacht-Upload Magic-Byte-Check**: multer prüfte nur
    den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als
    `application/pdf` kam durch. Neuer Check liest die ersten 5
    Bytes nach dem Upload und verlangt `%PDF-` – sonst wird die
    Datei gelöscht und 400 geliefert. Greift bevor irgendwas in
    der DB landet.
  - **28.4 Rate-Limit auf /api/public/consent**: 30 Requests pro
    IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash
    schon, aber ohne Limit konnte ein Angreifer das System per
    POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer
    `publicConsentRateLimiter` greift jetzt auf alle drei
    Sub-Routes (`/:hash`, `/:hash/grant`, `/:hash/pdf`).
  - **Live-verifiziert** auf dev:
    - `ftp://evil.com/x.js` → `blocked://evil.com/x.js`
    - `file:///etc/passwd` → `blocked:///etc/passwd`
    - `&#106;avascript:alert(1)` → `blocked:alert(1)`
    - `&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;OK` → `OK`
    - `&lt;script&gt;bad()&lt;/script&gt;Legit` → `Legit`
    - HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
    - Echtes PDF (Magic-Bytes ok) → 200
    - 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429

- [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix**
  - **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte
    `documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`
    durchließ (kein File-Read, aber dreckige Datenbasis). Neuer
    `isValidDocumentPath`-Check akzeptiert nur `/?uploads/<safe>`,
    alles andere wird auf `NULL` gesetzt.
  - Generischer `cleanupDocumentPaths`-Pass über die fünf weiteren
    Tabellen mit `documentPath` (BankCard, IdentityDocument, Invoice,
    RepresentativeAuthorization als nullable; ContractDocument
    NOT NULL → wird nur berichtet, manuell entscheiden).
  - **Orphaned User**: Neuer Report-Step `reportOrphanedUsers` warnt
    beim Container-Start vor User ohne Rollenzuordnung (= im
    Permission-System unsichtbar). Löschen tut das Skript nicht
    (false-positive-Risiko bei legitimen Spezial-Usern).
  - **Seed-PW-Policy**: `generateInitialPassword()` nutzte
    `Math.random()` (vorhersagbar). Jetzt `crypto.randomInt()` für
    Pick + Shuffle, 28 Zeichen aus 4 Klassen.
  - **`PUT /users/:id` mit `permissions` / `password`**: vorher
    silent-drop durch Whitelist + HTTP 200. Jetzt explizit HTTP 400
    mit Hinweis auf den dedizierten `/password`-Endpoint bzw. die
    Role-Steuerung. Gleicher Pattern wie `PUT /portal` für password.
  - **`/api/health` ohne Auth**: BEWUSST so – Container-Healthcheck +
    Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
    `{status,timestamp}` – keine Version, kein DB-Status, kein
    Hostname → kein Info-Leak. Kommentar im Code dokumentiert die
    Entscheidung.
  - **Live-verifiziert** auf dev:
    - CustomerConsent.documentPath=`../../../etc/passwd` → NULL
    - PUT /users mit `permissions` → 400 mit klarer Message
    - PUT /users mit `password` → 400 mit Hinweis auf /password
    - Orphan-User angelegt → vom Cleanup-Lauf gemeldet
    - `crypto.randomInt`-Pfad rauscht durch ohne Fehler

- [x] **🛡️ Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization**
  - **MEDIUM Consent-Mass-Assignment**: PUT `/api/gdpr/customer/:id/consents/:type`
    nahm `source`, `documentPath`, `version` ungefiltert aus dem Body
    – Portal-User konnte `source: "ADMIN_OVERRIDE"`, `version:
    "<script>"` oder `documentPath: "../../etc/passwd"` durchschmuggeln.
    Fix: nur noch `status` aus Body, source server-seitig hardcoded
    auf `'portal'`, documentPath/version bleiben NULL (werden vom
    dedizierten Authorization-Upload-Endpoint server-seitig gesetzt).
  - Whitelist für `CustomerConsent.source` ergänzt
    (`portal | public-link | telefon | papier | email | crm-backend`).
    `grantAuthorization` (Admin) erzwingt sie ebenfalls; `notes`
    läuft jetzt durch `stripHtml`.
  - **LOW javascript:-URI**: `stripHtml()` filtert jetzt zusätzlich
    `javascript:`, `data:`, `vbscript:` – ersetzt durch `blocked:`,
    damit `<a href={companyName}>` nichts feuert. Verträglich mit
    legitimem Text, der nicht "javascript:" enthält.
  - Cleanup-Skript erweitert um `cleanupConsents()`:
    Whitelist-Reset für source, stripHtml für version/documentPath
    – idempotent, läuft beim Container-Start automatisch.
  - **Live-verifiziert** auf dev:
    - PUT mit `{status:"GRANTED",source:"ADMIN_OVERRIDE",
      version:"<script>",documentPath:"../../etc/passwd"}` →
      DB hat `source=portal, documentPath=NULL, version=NULL`.
    - companyName `javascript:alert(1)` → `blocked:alert(1)`.
    - companyName `<a href=data:text/html,<script>...>Click</a>` → `Click`.
    - Cleanup auf dirty DB: source `ADMIN_OVERRIDE` → `unknown`,
      documentPath/version gesäubert.

- [x] **🚨 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body**
  - `POST /api/settings/backup/:name/restore` startete bei leerem
    Body sofort den destruktiven Restore. Im Unterschied zu
    `/factory-reset` fehlte der Magic-String-Confirm-Check. Risiko:
    versehentlicher Re-Fire (Doppelklick, Browser-Replay, eingeloggter
    Admin auf bösartiger Drittseite) überschrieb stillschweigend die
    komplette DB.
  - Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
    `{ "confirm": "RESTORE-BESTAETIGT" }` enthalten, sonst 400.
    Frontend-Client schickt den String beim Klick im Bestätigungs-
    Dialog automatisch (kein UX-Change für den User).
  - **Live-verifiziert** auf dev:
    - leerer Body → 400 "Bestätigung fehlt"
    - `{"confirm":"ja"}` → 400 (wrong)
    - `{"confirm":"RESTORE-BESTAETIGT"}` → 200, Restore lief

- [x] **🛡️ XSS-Sanitization für Plain-Text-AppSettings (Pentest MEDIUM)**
  - `companyName` (und weitere Plain-Text-Keys wie `defaultEmailDomain`,
    `monitoringAlertEmail`, Schwellenwerte) konnten via PUT
    `/api/settings/:key` mit XSS-Payloads befüllt werden – das war
    nur Admin-triggerbar, aber E-Mail-Templates/PDF-Generatoren
    hätten den Wert ungescaped rendern können.
  - Fix: neuer `sanitizeSettingValue(key, value)` in
    `appSetting.service.ts` strippt HTML außer für die expliziten
    HTML-Editor-Keys (`imprintHtml`, `privacyPolicyHtml`,
    `authorizationTemplateHtml`, `websitePrivacyPolicyHtml`).
    Greift in `updateSetting` (Einzel) und `updateSettings` (Bulk).
  - Cleanup-Skript erweitert: bestehende AppSettings mit HTML in
    Plain-Text-Keys werden beim Container-Start gestrippt
    (idempotent).
  - **Live-verifiziert** auf dev: `<img src=x onerror=alert(1)>OpenCRM
    <script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`.
    `imprintHtml` mit `<h1><p>` → unverändert.

- [x] **🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)**
  - Bestehende Installationen liefen weiter mit veraltetem
    Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
    weil `prisma db seed` per docker-entrypoint nur auf leeren DBs
    läuft. Folge: Settings.tsx-Fix vom Vorgänger-Commit half nicht,
    weil das JWT die fehlende Perm gar nicht enthielt.
  - Neuer Step im Entrypoint: `npx tsx prisma/sync-roles.ts` läuft
    bei jedem Start. Idempotent, fasst nur Permission- und
    Role-Tabellen an (keine User/Customers/Contracts), führt
    `syncRolePermissions` für Admin, Developer, DSGVO,
    Mitarbeiter, Mitarbeiter (Nur-Lesen), Kunde aus.
  - Live-verifiziert: `audit:read` aus DSGVO-Rolle gelöscht, Script
    laufen lassen → "+1 Permission an Rolle #27", wieder vollständig.

- [x] **🐛 DSGVO-Rolle: Menüpunkte in den Einstellungen unsichtbar**
  - Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei
    Karten unter Einstellungen → System (DSGVO-Dashboard,
    Datenschutzerklärung, Vollmacht-Vorlage, Impressum,
    Website-Datenschutz, E-Mail-Versandlog, Audit-Protokoll).
  - Ursache: Der gesamte System-Block in `frontend/src/pages/Settings.tsx`
    war in `hasPermission('settings:update')` eingewickelt. DSGVO
    hat aber nur `audit:*` und `gdpr:*` Perms – kein `settings:update`.
  - Fix: Outer-Check erweitert auf
    `settings:update || audit:read || gdpr:admin`. Jede Karte hat
    weiterhin ihren eigenen feingranularen Check; für DSGVO-User
    erscheinen nur die DSGVO-/Audit-Karten.
  - Backend-API mit reiner DSGVO-Rolle (kein Admin) live durchgetestet:
    `/api/gdpr/dashboard`, `/api/audit-logs`, `/api/email-logs`,
    `/api/gdpr/privacy-policy`, `/api/gdpr/authorization-template`,
    `/api/gdpr/imprint`, `/api/gdpr/website-privacy-policy`,
    `/api/gdpr/consents/overview`, `/api/gdpr/deletions` → alle 200.
    Backend war nicht das Problem.

- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
  - Vorher reine IP-basierte Sperre, was zwei Schwächen hatte:
    a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein
    b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro
       Account, dieselbe IP-only-Sperre umgangen.
  - Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
    DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
    Problem.
  - **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
    * Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
    * Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
    * Admin von IP-A mit richtigem PW → erfolgreicher Login
    * Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
  - Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
    in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher
    Email-only.
  - Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
    beides mit, Audit-Log resourceId = `${ip}|${email}`.
  - **Live-verifiziert** (4 Schritte):
    11x falsch max → 429, Nina/Admin von gleicher IP → durch,
    max bleibt gesperrt, Reset → max wieder 401.

- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400**
  - Endpoint nahm `password` silent entgegen, ignorierte es, gab
    aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort
    sei gesetzt. Fix: explizite Body-Validierung – `password`,
    `portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
    sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
    `POST /portal/password`-Endpoint.

- [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection**
  - **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
    die noch die alte Konvention vor der Refresh-Token-Trennung
    hatten (`JWT_EXPIRES_IN=7d`). docker-compose.yml und
    `.env.example` standen schon richtig auf 15m als Default.
    Alle `.env`-Files (Root, backend/, docker/.env.example,
    backend/.env.example) jetzt auf `JWT_EXPIRES_IN=15m` mit
    explizitem `JWT_REFRESH_EXPIRES_IN=7d`. Auf prod kann der
    Container mit dem neuen Default neu hochgezogen werden.
  - **17.5 Alte Pentest-Daten in DB**: das Cleanup-Script läuft
    schon bei jedem Container-Start, strippt HTML aus Customer/
    User-Strings und entfernt nicht-whitelisted AppSettings. Es
    erkannte aber keine Test-Records ohne HTML (z.B. Customer mit
    `email: hacker@evil.de`). Erweiterung:
    * Neue Marker-Pattern-Liste: `^hacker@`, `^attacker@`,
      `^pentest@`, `@evil\.`, `<script\b`, `onerror=`,
      `javascript:`, SQL-Injection + Path-Traversal-Pattern.
    * Bewusst eng: nur EMail-Adressen die mit dem Marker BEGINNEN,
      damit legitime Kunden mit "hacker" o.ä. im Nachnamen
      (z.B. `stefanhacker@gmx.de`) NICHT als Pentest-Marker
      durchgehen.
    * Default-Verhalten: nur warnen + Aufzählen. Mit
      `CLEANUP_PURGE_PENTEST=true`-ENV werden die markierten
      Customer/User-Records gelöscht.
    * Live-verifiziert: `stefanhacker@gmx.de` (echt) → durch;
      `hacker@evil.de` (Test) → erkannt + Warnung; mit Purge-Env
      → gelöscht.
  - **18.4 Klartext-Portal-PW-Abruf**: BEWUSST DRIN GELASSEN auf
    Wunsch (Admin-UI-Komfort). Klartext bleibt für Admin via
    `GET /customers/:id/portal/password` abrufbar; ist mit
    `customers:update`-Permission gated und mit Audit-Log
    (`READ → PortalPassword`) auditiert.

- [x] **🚨 Pentest Runde 15 – KRITISCH: portalPasswordHash in PUT/POST-Response**
  - **20.3 KRITISCH**: `PUT /customers/:id` gab den vollen bcrypt-Hash
    (`$2a$12$…`) zurück, weil `updateCustomer` Service-Output ohne
    sanitize-Aufruf direkt durchreichte. GET-Endpoints waren dicht,
    die Update-Response nicht. **20.4 HOCH** gleicher Klasse:
    `portalPasswordResetToken`, `consentHash`,
    `portalPasswordMustChange`, `portalTokenInvalidatedAt` leakten
    ebenfalls über PUT/POST.
  - **Fix**:
    * `updateCustomer` + `createCustomer` rufen jetzt
      `sanitizeCustomer`/`sanitizeCustomerStrict` auf den Service-
      Output (je nach `customers:update`-Permission).
    * `updateContract` + `createContract` + `createFollowUp` +
      `createRenewal` analog mit `sanitizeContract`/Strict
      (Portal-Hint via `req.user.isCustomerPortal`).
    * `portalPasswordMustChange` und `portalTokenInvalidatedAt`
      zusätzlich von `PORTAL_HIDDEN_CUSTOMER_FIELDS` zu
      `SENSITIVE_CUSTOMER_FIELDS` hochgezogen – damit greift der
      Schutz auch bei der normalen `sanitizeCustomer`-Variante
      (Admin-Sicht). Auch Pentester-Empfehlung in HOCH-Klasse.
  - **Live-verifiziert**:
    * Admin `PUT /customers/3 {firstName:…}` → 0 Leaks bei
      portalPasswordHash/ResetToken/Expires/MustChange/consentHash/
      TokenInvalidatedAt; `portalPasswordEncrypted` bleibt für
      Admin sichtbar (UI-Workflow)
    * Portal-User `GET /customers/3` → 0 Leaks auch bei
      portalPasswordEncrypted/notes
    * `POST /customers` (create) ebenfalls dicht

- [x] **🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)**
  - Use Case: Admin sperrt sich aus (z.B. `admin@admin.com` ist
    keine echte E-Mail → Passwort-vergessen-Flow kann keine Mail
    senden) oder Brute-Force-Lockout will sich nicht auflösen.
  - **Node-Script** `backend/prisma/reset-admin-password.ts`:
    findet User per Email, hasht PW mit bcrypt cost 12, schreibt
    direkt in `user.password`, setzt `tokenInvalidatedAt = now()`
    (kickt alle bestehenden Sessions), löscht ggf. anhängende
    Reset-Tokens. Mit oder ohne PW-Argument (random 28-char wenn
    leer), Komplexitäts-Check 25 Zeichen für eigene PWs.
  - **Bash-Wrapper** `scripts/admin-rescue.sh`:
    * `password <email> [pw]` – PW-Reset im laufenden Container
      via `docker exec opencrm-app npx tsx prisma/reset-admin-…`
    * `unlock` – Container-Restart leert den In-Memory-Rate-Limit-
      Store komplett (alle IPs frei)
    * `all <email> [pw]` – beides
  - **Live-verifiziert**: random-Modus liefert 28-char PW, schwaches
    eigenes PW wird mit allen Defizit-Punkten abgelehnt, langes
    eigenes PW akzeptiert, unbekannter User → klarer Fehler,
    bash-Syntax-Check ok.

- [x] **🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)**
  - 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI
    empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager
    → Länge kostet nichts, Sicherheitsgewinn ist real.
  - **Schwellwerte**: `STAFF_MIN_PASSWORD_LENGTH = 25`,
    `PORTAL_MIN_PASSWORD_LENGTH = 12` (Endkunden tippen das auch auf
    dem Handy ein).
  - **Backend-Pfade**:
    * `createUser` + `register` + `setUserPassword` → 25 Zeichen
    * `setPortalPassword` + `changeInitialPortalPassword` → bleibt 12
    * `confirmPasswordReset`: Server bestimmt Audience anhand des
      Tokens (`getPasswordResetAudience`) → User-Token = 25, Customer-
      Token = 12. Damit kann ein Angreifer nicht durch Body-Hint
      auf den schwächeren Schwellwert ausweichen.
  - **Seed-Admin**: Default-Passwort jetzt 28-char Zufallspasswort
    (alle 4 Klassen garantiert), via `SEED_ADMIN_PASSWORD`-ENV
    überschreibbar – aber nur wenn ≥ 25 Zeichen, sonst ignoriert
    mit Log-Warnung.
  - **Frontend**:
    * UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort
      gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer
      `POST /users/:id/password`) statt Passwort durch Update-Body
      durchzuschmuggeln.
    * PasswordResetConfirm: Hinweis "Mind. 12 Zeichen (Mitarbeiter:
      25)", Server entscheidet endgültig.
    * `userApi.setPassword(id, password)` neu in api.ts.
  - **Live-verifiziert**:
    * `POST /users/6/password "Hallo123!Test"` (12 chars) → 400
      "mindestens 25 Zeichen"
    * `POST /users/6/password "MeinExtremLangesPW2026!Test"` → 200,
      Login mit dem neuen PW → success
    * `POST /customers/3/portal/password "Hallo123!Test"` (12) → 200
    * `POST /users {…,password:"Hallo123!Test"}` → 400 (25-char-Floor)
  - **Nächster größerer Sprung** wäre **MFA für Mitarbeiter-Login**
    (TOTP via Authenticator). Eigenes Thema, separate Aufgabe.

- [x] **🚨 Pentest Runde 12 – Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW**
  - **M2-Reste (XSS-Strings noch in DB)**: neues idempotentes Script
    `prisma/cleanup-xss-and-mass-assignment.ts` läuft beim
    Container-Start. Strippt HTML aus Customer/User-String-Feldern;
    entfernt AppSettings, deren Key nicht in `ALLOWED_SETTING_KEYS`
    steht. Mehrfacher Aufruf ändert nichts.
  - **User-Update akzeptierte `password`-Feld**: stillschweigend ohne
    dedizierten Audit-Eintrag. Jetzt: `password` aus
    `USER_UPDATABLE_FIELDS` raus (CREATE behält es weiterhin); neuer
    Endpoint `POST /api/users/:id/password` mit eigenem Audit-Log
    "Passwort … durch Admin gesetzt", Komplexitäts-Check inklusive.
  - **JS-Runtime-Fehler leakten weiter**: ORM-Leak-Patterns erweitert
    um `TypeError`, `ReferenceError`, `SyntaxError`, `RangeError`,
    "Cannot read propert(y|ies) of (undefined|null)", "is not a
    function", "is not defined". Greift im globalen
    `res.json()`-Wrapper.
  - **POST /contracts substring-Crash**: defensiv – `type` fehlt →
    400 mit klarer Meldung; `generateContractNumber()` fängt auch
    leere/null type ab (Fallback "CON").
  - **Seed-Admin-Passwort "admin" verletzte Policy**: jetzt
    16-Zeichen-Zufallspasswort beim Seed (mit allen 4 Klassen
    garantiert) oder via `SEED_ADMIN_PASSWORD`-ENV überschreibbar;
    BCRYPT-Cost auf 12 (war 10); Passwort wird **einmalig** beim
    Seed in stdout ausgegeben mit Warnung.
  - **AppSettings-Whitelist ergänzt**: `companyName`,
    `defaultEmailDomain` (kommen aus seed.ts, waren in der ersten
    Whitelist vergessen).
  - **Live-verifiziert**: POST /contracts {} → klare 400 statt
    JS-Crash; PUT /users/6 {password:...} ignoriert (Login mit
    altem PW geht weiter); POST /users/6/password mit kurz → 400;
    Cleanup-Script: 1 Customer bereinigt, 2 unbekannte AppSettings
    entfernt (hackerSetting, debugMode), Re-Lauf → 0 Änderungen.

- [x] **🚨 Pentest Runde 11 – Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip**
  - **C2 KRITISCH – Factory Reset ohne Bestätigung**:
    Eingeloggter Admin konnte mit leerem oder beliebigem Body
    (`{confirm:true}`, `{confirm:false}`, `{}`) die komplette DB
    plätten (3× in einer Session passiert). Fix: server-side wird
    jetzt `confirm: "FACTORY-RESET-BESTAETIGT"` als String erzwungen,
    sonst HTTP 400. Frontend-API schickt den exakten String mit.
  - **M1 – Settings Mass Assignment**:
    `PUT /api/settings` und `PUT /api/settings/:key` nahmen JEDEN
    Key-Value-Pair an (`superAdminEmail`, `debugMode`,
    `allowedOrigins` etc. landeten direkt in der DB). Fix:
    Whitelist `ALLOWED_SETTING_KEYS` in `appSetting.service.ts`,
    Helper `isAllowedSettingKey()`. Unbekannte Keys → HTTP 400 mit
    expliziter Aufzählung der ungültigen Keys.
  - **M3 – Prisma-Error-Leak in jeder Response**:
    Statt 30+ Controller einzeln zu fixen: globaler `res.json()`-
    Wrapper unter `/api`, der `error`/`details`-Strings durch einen
    Pattern-Filter schickt. Bekannte ORM-/Stack-Trace-Muster
    (`Invalid \`prisma.`, `PrismaClient`, Stack-Frames) werden zu
    `"Operation fehlgeschlagen"` ersetzt. Original-Text bleibt im
    Server-Log via `[orm-leak-guard]`.
  - **M2 – Stored XSS in Customer/User-Strings**:
    `<script>alert(1)</script>` und ähnliche Payloads landeten
    ungefiltert in der DB. Fix: neuer `stripHtml()`-Helper, von
    `pickCustomerUpdate/Create` und `pickUserUpdate/Create` auf
    allen String-Werten angewandt (Defense-in-Depth – React
    auto-escaped schon, aber PDF-Generator/E-Mail-Templates
    könnten exec-Vektoren sein).
  - **Live-verifiziert (alle vier)**:
    * `/factory-reset` mit `{}`, `{confirm:true}`, `{confirm:false}`
      → HTTP 400, DB unangetastet
    * `PUT /settings {superAdminEmail,debugMode,allowedOrigins}` →
      400 + Keys aufgezählt; gültige Keys → 200
    * `PUT /users/99999` → `"Operation fehlgeschlagen"` statt
      Prisma-Stack; Server-Log behält Original
    * `PUT /customers/3 {companyName:"<script>...</script>EvilCorp"}`
      → gespeichert als `"EvilCorp"`; `<img onerror>` weg

- [x] **🚨 Pentest Runde 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
  - **MEDIUM – Stale Token nach Vollmacht-Widerruf**:
    Selbst ein FRISCHER Portal-Login lieferte JWT mit
    `representedCustomerIds: [7]` und `representedCustomers: [{Nina,…}]`,
    obwohl die Vollmacht widerrufen war. Live-Check beim Datenzugriff
    funktionierte (403), aber die UI zeigte dem Vertreter weiter, dass
    er Nina vertreten könne.
    * **Fix**: `customerLogin` und `getCustomerPortalUser` (= /me +
      Refresh-Pfad) filtern `representingFor` jetzt zusätzlich über
      `getAuthorizedCustomerIds()` – nur Beziehungen mit
      `isGranted: true` landen im Token und in /me.
    * Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten
      widerrufen) → JWT.representedCustomerIds = `[]`, /me ebenfalls.
  - **MEDIUM – DTO-Leak in embedded Objekten**:
    `GET /customers/:id` lieferte zwar Customer-Top-Level sanitisiert,
    aber `contracts[]` darin enthielt weiterhin `commission`, `notes`,
    `portalPasswordEncrypted`, `nextReviewDate`. Analog `notes` auf
    embedded customer in `/contracts/:id`.
    * **Fix**: `sanitizeCustomer(Strict)` ruft jetzt
      `sanitizeContract(Strict)` für jedes Element in `contracts[]`
      auf. `notes` zu `PORTAL_HIDDEN_CUSTOMER_FIELDS` ergänzt
      (interne CRM-Vermerke).
    * Verifiziert: Portal-User sieht in `customers/1.contracts[*]`
      keine commission/notes/PW-Encrypted/nextReviewDate mehr;
      Admin sieht sie weiterhin (Workflow-Bedarf);
      `portalPasswordEncrypted` ist generell entfernt (Klartext nur
      via `/contracts/:id/password` mit Audit-Log).
  - **LOW – `/tasks?customerId=X` 200 statt 403 für fremde IDs**:
    Konsistenz-Issue: nach Vollmacht-Widerruf gab der Endpoint
    leeres Array statt einen klaren 403-Fehler. Jetzt: wenn der
    Portal-User explizit nach einer customerId filtert, die er nicht
    (mehr) vertreten darf → 403 mit "Kein Zugriff auf diese
    Kundendaten". Verifiziert.

- [x] **🚨 Pentest Runde 7 (Anschlussrunde) – Information-Disclosure + Input-Validation**
  - **MEDIUM – Interne Felder in Portal-Responses**:
    * `sanitizeCustomerStrict` strippt jetzt zusätzlich
      `portalTokenInvalidatedAt`, `portalLastLogin`,
      `portalPasswordMustChange`, `lastBirthdayGreetingYear`,
      `privacyPolicyPath`, `businessRegistrationPath`,
      `commercialRegisterPath`.
    * Neue `sanitizeContract` / `sanitizeContractStrict` /
      `sanitizeContracts(Strict)`: entfernt
      `portalPasswordEncrypted` (immer; ist nur über den dedizierten
      `/password`-Endpoint mit Audit-Log abrufbar) und für Portal-
      User zusätzlich `commission`, `notes`, `nextReviewDate`.
    * `getContract` + `getContracts` rufen jetzt die passende
      Sanitize-Variante je nach `req.user.isCustomerPortal` auf;
      Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow),
      nur `portalPasswordEncrypted` ist generell entfernt (Klartext
      nur über dedicated Endpoint).
    * Live-verifiziert: Portal sieht 0 Leaks, Admin sieht
      commission/notes weiterhin.
  - **LOW – Integer-Truncation bei IDs**:
    `parseInt('6abc')` → `6` hat alle Endpoints durchgewunken.
    Neuer middleware in `index.ts`: jedes URL-Pfad-Segment unter
    `/api`, das mit Ziffer beginnt aber nicht aus reinen Ziffern
    besteht, wird mit HTTP 400 abgelehnt. Heuristik trifft alle
    `/resource/<id>(\D+)`-Patterns ohne dass jeder einzelne
    Sub-Router angefasst werden muss.
    * Live-verifiziert: `/customers/6abc` → 400 mit klarer Meldung,
      `/customers/3` weiterhin 200, `/contracts/1abc/history`
      → 400, normaler Pfade `/audit-logs/customer/3` → 200.
  - **INFO – Login-Rate-Limit „nach 6 nicht aktiv"**:
    Code-Stand `limit: 10` für `loginRateLimiter`, lokal verifiziert:
    11. Versuch = 429. Pentester sah vermutlich noch alten Build
    oder eine andere Lokation (PW-Reset hat `limit: 5`). Kein
    Code-Change.

- [x] **🛠 Rate-Limit-Sperren: Admin-UI zum Freigeben**
  - Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele
    Login-Versuche ausgesperrt → ohne Container-Restart kein Weg zurück.
    Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben.
  - **Datenquelle für die Liste**: `SecurityEvent`-Tabelle filtert nach
    `type = RATE_LIMIT_HIT` im 15-Min-Fenster (= Login-Window), gruppiert
    nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ
    (Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit.
  - **Reset**: ruft `loginRateLimiter.resetKey(ip)` und
    `passwordResetRateLimiter.resetKey(ip)` auf – exposiert von
    `express-rate-limit` v7. Idempotent, audited.
  - **Backend**:
    * `GET /api/settings/rate-limits/active` (`settings:read`)
    * `POST /api/settings/rate-limits/reset` (`settings:update`) mit
      Body `{ ipAddress }`
    * neuer Controller `rateLimitAdmin.controller.ts`
  - **Frontend**: neue Seite `/settings/rate-limits` mit Tabelle +
    Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Übersicht
    (orange, neben „Sicherheits-Monitoring").
  - **Live-verifiziert (4 Schritte)**: 11 falsche Logins von
    127.0.0.1 → 11. → 429; Liste zeigt IP + Email + Hits;
    POST Reset → 200; nächster Login mit falschem PW → 401 statt
    429 (Sperre weg); Audit-Log enthält Eintrag.

- [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens**
  - **Credential-Endpoints** (Contracts password/internet/sip/simcard +
    Stressfrei mailbox/send/reset-password): ALLE bereits durch
    `canAccessContract`/`canAccessStressfreiEmail` gesichert – keine
    Lücke gefunden.
  - **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort-
    Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefügt.
    Defense in depth gegen versehentlich falsch vergebene
    `customers:update`-Permission.
  - **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash,
    audit-logs/customer): alle durch admin-level Permissions
    (`settings:update`, `developer:access`, `audit:admin`, `audit:read`)
    geschützt – Portal-User haben diese nicht.
  - **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als
    `?token=` in URLs für PDF-iframe, Audit-Log-Export, PDF-Generate
    und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History,
    Referer-Header.
    * **Neuer Mechanismus**: `POST /api/auth/download-token` liefert
      ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`.
    * Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH
      via `?token=` Query, niemals als Bearer-Header. So kann ein in
      Logs geleaktes Download-Token nicht für reguläre API-Aufrufe
      missbraucht werden.
    * Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export,
      PDF-Template-Preview, PDF-Generate von ContractDetail + Modal,
      Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind
      synchron und in vielen Components verstreut – Migration dieser
      bleibt als Folge-Aufgabe.
    * Live-verifiziert: Download-Token = 1773 Zeichen, type=download,
      exp-iat=60s, als Header → 401, als ?token= → 200.

- [x] **🚨 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)**
  - **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check →
    Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix:
    `canAccessCachedEmail` im Controller.
  - **KRITISCH-02 `GET /customers/:customerId/representatives/search`**:
    kein `canAccessCustomer` auf den Pfad → DSGVO-GAU, Portal-Kunde
    konnte mit Buchstaben-Brute-Force die komplette Kunden-DB
    auslesen. Fix eingefügt.
  - **HOCH-01 `GET /birthdays/upcoming`**: kein Portal-Filter → Name,
    E-Mail, Telefon, Geburtsdatum aller Kunden lesbar. Fix:
    `isCustomerPortal` → 403.
  - **HOCH-02 `*/contracts/:contractId/history`**: kein Owner-Check
    auf GET/POST/PUT/DELETE. Fix: `canAccessContract` in allen vier
    History-Handlern.
  - **HOCH-03 Mailbox-Endpoints**: `mailbox-accounts`, `unread-count`,
    `contracts/:id/emails/folder-counts` ohne Check. Fix:
    `canAccessCustomer` bzw. `canAccessContract` in allen drei.
  - **HOCH-04 Live-Vollmacht-Check in Tasks**: `getTasks`,
    `createSupportTicket`, `createCustomerReply`, `getAllTasks`,
    `getTaskStats` prüften nur `representedCustomerIds.includes(...)`
    aus dem JWT – widerrufene Vollmachten hatten weiter Zugriff
    (JWT lebt bis zu 15min nach Widerruf). Neuer Helper
    `getPortalAllowedCustomerIds()` in `accessControl.ts` ruft
    `hasAuthorization()` live ab. Auch `updateCustomerConsent`
    (GDPR) auf diesen Pfad umgestellt.
  - **MITTEL-01 `confirmPasswordReset` Klartext-Speicherung**:
    Self-Service-Reset speicherte `portalPasswordEncrypted = encrypt(pw)`.
    Klartext-Speicherung ist nur für Admin-OTPs sinnvoll. Fix:
    Field auf null, zusätzlich `portalPasswordMustChange = false`.
  - **MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl**:
    `GET /customers` gab `total: 4271` auch wenn Portal-User nur
    1 Kunde sah. Fix: `customer.service.ts` erweitert um
    `allowedIds`-Filter, der direkt in der DB-Query landet → die
    pagination zählt nur über erlaubte IDs.
  - **Strukturelles Audit-Sweep** (Sub-CRUD + Email-Operationen):
    Folgende Handler bekamen jetzt erstmals einen `canAccess*`-
    Check, defense in depth gegen falsch vergebene Rollen:
    `markAsRead`, `toggleStar`, `assignToContract`,
    `unassignFromContract`, `deleteEmail`, `getTrashEmails`,
    `getTrashCount`, `restoreEmail`, `permanentDeleteEmail`,
    `getAttachmentTargets`, `saveAttachmentTo`, `saveEmailAsPdf`,
    `saveEmailAsInvoice`, `saveAttachmentAsInvoice`,
    `saveAttachmentAsContractDocument`, `createFollowUp`,
    `createRenewal`, `snoozeContract`, `removeContractMeter`,
    `updateAddress`, `deleteAddress`, `updateBankCard`,
    `deleteBankCard`, `updateDocument`, `deleteDocument`,
    `updateMeter`, `deleteMeter`, `addMeterReading`,
    `updateMeterReading`, `deleteMeterReading`,
    `markReadingTransferred`, `addRepresentative`,
    `removeRepresentative`.
  - **Live-verifiziert** (Portal-User Customer 3 auf fremde IDs):
    `customers/1/representatives/search` → 403,
    `birthdays/upcoming` → 403 (Admin → 200),
    `emails/21/thread` → 403,
    `customers/2/mailbox-accounts` → 403,
    `emails/unread-count?customerId=2` → 403,
    `contracts/8/{history,folder-counts,follow-up,renewal,snooze}` → 403,
    eigene `customers/3` → 200,
    pagination.total für Portal = 1 (statt 3),
    Customer 1 mit widerrufener Vollmacht → 0 fremde Verträge.

- [x] **🚨 Pentest Runde 5 – KRITISCH: change-initial-portal-password ohne Pflicht-Check**
  - **Realer Angriff**: Jeder Portal-User konnte jederzeit mit
    seinem eingeloggten Token `POST /api/auth/change-initial-portal-
    password` aufrufen und das eigene Passwort ohne Kenntnis des
    alten ersetzen. Der OTP-Flow-Endpoint hatte den Check
    `portalPasswordMustChange === true` nicht.
  - **Konsequenz**: Bei XSS oder kurzlebigem Token-Diebstahl konnte
    ein Angreifer das Passwort dauerhaft übernehmen.
  - **Fix**: Eine Zeile in `auth.controller.ts` –
    `prisma.customer.findUnique` auf `portalPasswordMustChange`,
    bei `false` → 403 "Nicht erlaubt".
  - **Live-verifiziert**: ohne Flag → 403; mit Flag (nach
    send-credentials) → 200, danach Flag automatisch zurück auf
    `false` → erneuter Aufruf → 403.

- [x] **Pentest Runde 5 – NIEDRIG: consentHash + Public-Grant-Response**
  - `consentHash` wurde über `GET /api/customers/:id` zurückgegeben.
    Der Hash ist Pseudo-Credential für den öffentlichen Consent-Link
    (wer ihn hat, sieht Customer-Name + Kundennummer ohne Auth und
    kann Einwilligungen erteilen). **Fix**: in
    `SENSITIVE_CUSTOMER_FIELDS` aufgenommen. Wer ihn legitim braucht,
    holt ihn über `/gdpr/customer/:id/consent-status` (eigener Check).
  - `POST /api/public/consent/:hash/grant` gab den vollen
    `CustomerConsent[]`-Array inkl. IP-Adressen und `createdBy`
    (Kunden-Name) zurück. **Fix**: Response auf
    `{ granted: <count> }` reduziert. Frontend nutzt eh nur
    `success`-Flag.
  - **Live-verifiziert**: `consentHash: null` in customer-Response,
    `consentHash` weiterhin in `/gdpr/.../consent-status`,
    Grant-Response liefert nur `{granted: 4}` ohne Extra-Keys.

- [x] **🚨 Pentest Runde 4 – HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)**
  - **Realer Angriff**: Portal-User Max bekam mit seinem Token
    `GET /api/contracts/cockpit` → komplette Vertragsliste ALLER
    Kunden (Customer-Namen, Vertragsnummern, Statūs).
  - **Root Cause**: `contractCockpitService.getCockpitData()` filterte
    nicht nach Customer, weil das Cockpit ursprünglich nur für Admins
    gedacht war. Die `contracts:read`-Permission haben aber auch
    Portal-User → Endpoint war erreichbar.
  - **Fix**: Service-Signatur erweitert auf
    `getCockpitData({ customerIds? })`. Wenn `customerIds` gesetzt
    sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis-
    Warnungen und gemeldete Zählerstände allesamt auf diese IDs
    eingeschränkt. Controller bestimmt `customerIds` analog zu
    `getContracts`: bei `isCustomerPortal` → eigene + vertretene
    Kunden (nur mit Vollmacht); sonst undefined (= alle).
  - **Live-verifiziert**: Admin sieht 17 Verträge (3 Kunden);
    Portal-User Customer 1 sieht 12 (nur seine); Portal-User
    Customer 3 sieht 3 (nur seine); 0 Leaks.

- [x] **🚨 Pentest Runde 3 – drei Findings gefixt**
  - **KRITISCH – `POST /api/developer/setup` ohne Auth (Privilege
    Escalation)**: Endpoint war komplett ohne Authentifizierung
    erreichbar und konnte der Admin-Rolle die `developer:access`-
    Permission verleihen → kompletter DB-Zugriff über `/developer/*`.
    **Fix**: Endpoint ersatzlos gelöscht. Manuelles Setzen geht
    weiterhin über `prisma/add-developer-permission.ts` (CLI).
    Live-verifiziert: `POST /api/developer/setup` → HTTP 404.
  - **HOCH – Customer-Login DoS auf Prod (fehlende Migration)**:
    `portalPasswordMustChange` war im Code, aber prod-DB kannte die
    Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause:
    in dieser Session wurde `prisma db push` benutzt (kein Migration-
    File). **Fix**: handgenerierte Migration
    `20260516173552_portal_password_must_change/migration.sql` (via
    `prisma migrate diff` + `migrate resolve --applied`). Verifiziert
    durch shadow-DB-Reset + `migrate deploy`: Spalte landet korrekt
    in einer frischen DB. `entrypoint.sh` führt `migrate deploy`
    beim Container-Start bereits aus → Prod-Restart applied jetzt
    automatisch.
  - **MITTEL – Prisma-Internals-Leak im Login-Error-Body**: Bei
    unerwarteten Fehlern (Schema-Bruch, DB-Down) wurde
    `error.message` direkt zurückgegeben → Tabellen-/Spaltennamen
    leakten. **Fix**: Whitelist-Filter `safeLoginError()` in
    `auth.controller.ts`: nur bekannte Messages
    (`'Ungültige Anmeldedaten'`, `'E-Mail und Passwort
    erforderlich'`) werden durchgereicht, alles andere wird zu
    generischem `'Anmeldung fehlgeschlagen'` und das Original
    landet im Server-Log. Greift für Mitarbeiter- UND Portal-
    Login. Live-verifiziert: Spalte testweise gedropped → Client
    sieht generisch, Server-Log enthält Original.

- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
  - **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
    schicken, kennen wir das Passwort als Admin – das ist solange OK,
    bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein,
    sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
  - **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean
    @default(false)` am Customer.
  - **Flow**:
    1. Admin klickt **Zugangsdaten versenden** → Flag wird gesetzt,
       Mail-Template weist explizit auf „Einmalpasswort" hin.
    2. Kunde loggt sich mit dem OTP ein → Backend gibt
       `mustChangePassword: true` im Login-Response zurück UND
       **konsumiert das OTP sofort**: setzt `portalPasswordHash =
       null` und `portalPasswordEncrypted = null`. Ein zweiter
       Login mit demselben Passwort schlägt fehl (401).
    3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true`
       und leitet auf `/change-initial-password` um – egal welche
       Route der Kunde aufrufen will, er kommt nicht weiter.
    4. Auf der Seite gibt er ein neues, komplexes Passwort vor
       (Live-Hint mit ✓/○, dieselben Regeln wie Backend).
    5. `POST /api/auth/change-initial-portal-password` speichert
       neuen Hash, **löscht das Encrypted-Feld** (Admin kann das
       eigene Passwort des Kunden nicht mehr im Klartext lesen),
       setzt `portalTokenInvalidatedAt = now()` und
       `portalPasswordMustChange = false`.
    6. Frontend loggt aus, leitet zu `/login?changed=1`,
       Erfolgs-Banner: „Passwort wurde geändert. Bitte mit dem
       neuen Passwort anmelden."
  - **Edge case**: Tab geschlossen ohne Setzen → Kunde ist
    ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung
    aus seiner Sicht: Passwort-vergessen-Funktion oder Admin
    versendet neue Zugangsdaten.
  - **Edge case**: Admin macht zwischendurch nochmal manuelles
    „Setzen" → `mustChange` wird automatisch wieder `false`. So
    kann ein versehentlich versendetes OTP problemlos durch ein
    direkt-gesetztes Passwort ersetzt werden.
  - **Live-verifiziert (10 Schritte)**: Setzen → Send → Flag in
    DB=true → Login mit OTP gibt mustChange=true zurück + Hash
    in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint
    schwach → 400 → komplex → 200 → Login mit neuem PW →
    mustChange=false + tokenInvalidatedAt gesetzt.

- [x] **🔐 Passwort-Komplexität + Portal-Credentials-UX**
  - **Problem**: Bisher reichten 6 Zeichen für gesetzte Passwörter
    (Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat
    der Pentest bemängelt, und es entsprach auch nicht dem, was wir
    selbst von Endkunden erwarten würden.
  - **Lösung**:
    * `validatePasswordComplexity()` in `passwordGenerator.ts`:
      mind. 12 Zeichen + Großbuchstaben + Kleinbuchstaben + Ziffer
      + Sonderzeichen, mit detaillierter Fehlerliste auf deutsch.
    * Erzwungen in **5 Endpoints**: `setPortalPassword`,
      `confirmPasswordReset`, `register`, `createUser`, `updateUser`.
  - **Neue UX im Kunden-Portal-Block (CustomerDetail)**:
    * **Generate-Button**: erzeugt 16-Zeichen-Zufallspasswort, das
      garantiert allen Komplexitätsregeln entspricht, und füllt
      das Eingabefeld direkt aus.
    * **Send-Credentials-Button**: schickt Login-URL + Username +
      Klartext-Passwort an die Kunden-E-Mail. Funktioniert nur,
      wenn "Portal aktiviert" tatsächlich aktiviert ist.
    * **Live-Komplexitäts-Hint** beim Tippen: ✓/○-Liste zeigt
      sofort, welche Regeln noch fehlen.
    * `alert()`-Boxen durch Toast-Notifications ersetzt.
  - **Live-verifiziert**: schwaches Passwort `hallo123` → HTTP 400
    mit Fehlerliste, komplexes Passwort `Hallo123!Test` → HTTP 200,
    Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials
    versendet Mail nur bei portalEnabled=true.

- [x] **🌐 Real-IP hinter Nginx-Proxy-Manager**
  - **Problem**: Rate-Limiter und Security-Monitor haben statt der
    echten Client-IP nur die NPM-IP (`172.0.2.12`) geloggt. Damit
    wären alle Threshold-basierten Blockings nutzlos – ein Brute-
    Force von 100 verschiedenen Clients wäre für uns 1 Quelle.
  - **Root Cause**: `app.set('trust proxy', 'loopback')` – das passt
    nur, wenn der Proxy auf 127.0.0.1 läuft. NPM läuft aber auf
    einem anderen Host, also wurde X-Forwarded-For ignoriert.
  - **Fix**: trust-proxy abhängig von `HTTPS_ENABLED`:
    `HTTPS_ENABLED=true` → `1` (genau 1 Hop, der NPM), sonst
    `loopback` (Direkt-Verbindungen lokal).
  - **Live-verifiziert**: req.ip zeigt jetzt die echte Browser-IP
    statt der NPM-IP, Threshold-Events triggern korrekt.

- [x] **🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)**
  - **Realer Angriff erfolgreich durchgespielt**: Portal-User konnte über
    `/api/stressfrei-emails/{id}/credentials` die kompletten Klartext-
    IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen.
  - **Root Cause**: der Haupt-Endpoint `GET /:id` hatte
    `canAccessStressfreiEmail`-Check, die **8 Sub-Endpoints** unter
    `:id/*` hatten alle KEINEN Ownership-Check (nur `authenticate +
    requirePermission('customers:read')`, was Portal-User von Haus aus
    haben).
  - **Fix**: `canAccessStressfreiEmail(req, res, id)` als erste Zeile in
    allen 9 betroffenen Controllern: `getMailboxCredentials`,
    `getFolderCounts`, `syncAccount`, `sendEmailFromAccount`,
    `enableMailbox`, `syncMailboxStatus`, `resetPassword`, `updateEmail`,
    `deleteEmail`.
  - **Security-Monitor**: `canAccessResourceByCustomerId` emittiert
    bei jedem Fehlversuch automatisch ein `ACCESS_DENIED MEDIUM`-Event
    → Threshold-Detection (>5 in 5 min) erzeugt `CRITICAL SUSPICIOUS` +
    Sofort-Alert.
  - **Live-verifiziert**: Portal-User Kunde A probiert Email-ID von
    Kunde B durch alle 8 Sub-Routes → **alle 8× HTTP 403**, eigene
    Email-ID kommt sauber durch (200/400), 8× `ACCESS_DENIED`-Events
    im Security-Monitor.

- [x] **🛡️ JWT-Tokens raus aus localStorage – Refresh-Cookie-Pattern**
  - Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS
    den Token klauen + alle Anbieter-Credentials abrufen. Lösung:
    Branchenstandard für SPAs.
  - **Access-Token**: kurzlebig (15 min), lebt nur im
    JavaScript-Memory (Modul-State + AuthContext). Kein localStorage
    mehr → XSS-Angriff klaut maximal einen 15-min-Token, mit dem er
    eh nicht weit kommt.
  - **Refresh-Token**: 7 Tage Lifetime, im **httpOnly-Cookie** (`Secure`
    bei HTTPS_ENABLED, `SameSite=Strict`, `Path=/api/auth`). JavaScript
    hat **keinen Zugriff** → XSS kann ihn nicht klauen.
  - Backend:
    * `signAccessToken/signRefreshToken` mit `type`-Claim als
      Unterscheidung; Auth-Middleware lässt nur `type=access` durch
    * Login + Customer-Login setzen Cookie + geben Access im Body
    * `POST /api/auth/refresh` liest Cookie, gibt neuen Access aus,
      rotiert Refresh-Cookie, prüft `tokenInvalidatedAt`
      (sofortige Invalidation bei Rolle-Ändern/Logout)
    * Logout löscht Cookie + setzt `tokenInvalidatedAt`
    * `cookie-parser` als neue dependency
  - Frontend:
    * `api.ts`: in-memory `tokenStore` + axios-Interceptor mit
      Auto-Refresh-Retry bei 401 (single-flight gegen
      Concurrent-Requests)
    * `AuthContext`: beim App-Start `/auth/refresh` aufrufen → wenn
      Cookie noch gültig, ist der User automatisch eingeloggt
      (kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
    * 9 alte `localStorage.getItem('token')`-Stellen migriert auf
      `getAccessToken()` (PDF-Vorschau-iframe, Audit-Log-Export,
      Backup-Download, File-Download-URL, …)
  - Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit
    Bearer→200, ohne→401, Refresh-Endpoint rotiert Cookie sauber,
    Refresh-Token wird als Bearer (Access) abgelehnt („Falscher
    Token-Typ"), Logout löscht Cookie + invalidiert Token.

- [x] **🔒 Audit-Log für alle Klartext-Passwort-Reads**
  - Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH,
    post-auth)" → reversible Verschlüsselung ist by-design (Feature
    „Anbieter-Login anzeigen" braucht es), aber jeder Decrypt-Vorgang
    sollte im Audit-Log auftauchen. Bisher: keiner der 6 Endpoints
    schrieb ein Log.
  - Audit-Logs jetzt für: `getPortalPassword`, `getContractPassword`,
    `getSimCardCredentials`, `getInternetCredentials`,
    `getSipCredentials`, `getMailboxCredentials`.
  - `action: 'READ'`, eigene Resource-Types (PortalPassword,
    ContractPassword, SimCardCredentials, InternetCredentials,
    SipCredentials, MailboxCredentials), alle mit `sensitivity:
    CRITICAL` über die Sensitivity-Map.
  - Label nennt explizit „Klartext … entschlüsselt" + Ressourcen-ID,
    damit im Audit-Log-Viewer auf einen Blick erkennbar ist, was
    passiert ist (DSGVO-Nachvollziehbarkeit + Insider-Threat-Erkennung).

- [x] **↗ E-Mail-Postfach: Weiterleiten + Erneut senden**
  - **Weiterleiten** (Compose-Modal-Erweiterung): neuer Button im
    EmailDetail öffnet das ComposeEmailModal im Forward-Modus –
    To-Feld leer (User trägt den neuen Empfänger ein), Betreff mit
    „Fwd:"-Prefix, Body mit zitierten Original-Headern (Von, An,
    Datum, Betreff) + Original-Text.
  - **Erneut senden** (One-Click): schickt die Mail noch einmal an
    die ursprüngliche Empfänger-Adresse (= die Stressfrei-Adresse
    selbst). Damit läuft sie durch die heute hinterlegten Forwards
    und landet beim aktuell konfigurierten Kunden-Postfach – Use-Case:
    Stressfrei-Adresse wurde nach Empfang umgestellt, Original ist nur
    in der alten Inbox. Confirm-Dialog mit Hinweis, dass Anhänge nicht
    erneut mit gesendet werden (Weiterleiten dafür nutzen). Toast für
    Erfolg/Fehler.

- [x] **🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)**
  - Suchleiste über der Email-Liste – durchsucht parallel Subject,
    From-Address/Name und Body.
  - Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit
    Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis,
    Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status.
    Alle Filter werden im Backend mit UND verknüpft.
  - „Alle zurücksetzen"-Button räumt komplett auf.
  - Backend: `GET /api/customers/:id/emails` nimmt die Filter als
    Query-Parameter entgegen, `getCachedEmails` übersetzt sie in eine
    Prisma `where`-Klausel.
  - **Bewusst nicht gebaut**: voller AND/OR-Builder mit Plus-Button und
    Bool-Verschachtelung – Trade-off-Diskussion mit User: reale
    Use-Cases sind quasi immer AND, UI-Komplexität verschachtelter
    Bool-Builder bringt mehr Bedienprobleme als Mehrwert.

- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
  - Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
    (Tooltip erklärt: „ersetzt die Forwards am Provider durch
    Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Änderung der
    Stamm-E-Mail eines Kunden, oder nach Wechsel der
    `defaultForwardEmail` in den Provider-Settings.
  - **Bei `hasMailbox: true`** wird zusätzlich das im CRM verschlüsselt
    hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing
    für den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort
    gesetzt hat und IMAP/SMTP im CRM nicht mehr passt.
  - Backend nutzt Plesk's `updateForwardTargets` (`set:email1,email2`
    → ersetzt komplett, idempotent) + bei Mailbox auch
    `updateMailboxPassword` (Plesk-Passwort-Update).
  - Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`,
    `customers:update`-Permission, Audit-Log mit Forward-Targets +
    Passwort-Reset-Marker.
  - Self-Healing: `isProvisioned`-Flag wird bei erfolgreichem
    Provider-Aufruf automatisch auf `true` korrigiert (historischer Bug:
    Flag wurde beim `createEmail` mit `provisionAtProvider: true` nie
    gesetzt – jetzt behoben + Backfill via Sync).
  - Erfolgs-/Fehler-Meldungen via `react-hot-toast` (statt `alert()`)
    mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset
    durchgeführt wurde.
  - In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes
    Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
    öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind.

- [x] **🛡️ Pentest-Hardening-Runde 11: Header-Hygiene**
  - **HSTS-Doppel-Header** (18× low im Audit): Helmet's
    `Strict-Transport-Security` komplett deaktiviert. Der Nginx Proxy Manager
    vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797.
  - **Cache-Control** (≥10× info im Audit):
    `/api/*` bekommt `no-store` (sensible JSON-Daten),
    SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`, `/vite.svg`) bekommt
    `no-store, must-revalidate` (sonst hängt Browser an alter index.html
    fest nach Deploy),
    `/assets/*` (Vite-Build mit Content-Hash im Filename) bekommt
    `public, max-age=31536000, immutable`.
  - **CSP No-Fallback-Direktiven** (2× medium): `worker-src`, `manifest-src`,
    `media-src` explizit auf `'self'` – ZAP markiert sonst „Failure to
    Define Directive with No Fallback".
  - Bewusst NICHT angefasst: `style-src 'unsafe-inline'` (Tailwind/React-
    inline-styles, kompletter Refactor unverhältnismäßig).
  - Live verifiziert: Headers für `/`, `/api/*`, `/assets/*.js` und SPA-
    Fallback-Pfade alle wie erwartet.

- [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 1–3: 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)
