Files
opencrm/docs/todo.md
T
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00

176 KiB
Raw Blame History

📋 OpenCRM Todo-Liste


🔜 Offen

Manuelle Tests (vor Release durchklicken)

Checklisten für Security + Email-Log-System stehen in 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

  • 🗂️ 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.
  • 🔒 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.
  • 🪪 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.
  • 📄 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>).
  • 📇 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.
  • 🛡️ 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:''.
  • 🔄 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 (1365).
    • 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.
  • 🏦 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).
  • 📌 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.
  • 📋 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).
  • 🚫 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.
  • 🧾 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).
  • 📦 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).
  • 🔐 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).
  • 🎁 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).
  • 🔗 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.
  • 🚫 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: ". 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.
  • 📄 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.).
  • 📋 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).
  • 📧 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.).
  • 🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)

    • Neuer Tab vor „Datenschutz", nur Mitarbeiter/Admin (nicht Portal), ohne Consent-Pflicht nutzbar. Zwei Abschnitte:
      1. „ wurde an Board geholt durch:" max. 1 Werber (DB-Unique auf recruitedId).
      2. „ 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).
  • 🔄 „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).
  • 👁 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.
  • 🔒 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.
  • 🔒 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.)
  • 🐞 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.
  • 📧 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).
  • 📱 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.
  • 🐞 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.
  • 🎂 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.
  • 🔴 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.
  • 🔧 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.
  • 🔴 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). 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.
  • 👁 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.
  • 🐞 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.
  • 📝 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).
  • 🔕 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.
  • 🔧 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.
  • 🔗 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).
  • 🔧 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.
  • 🔒 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.
  • 🐞 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.
  • 🐞 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.
  • 🆕 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.
  • 🔒 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).
  • 🔒 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.
  • 🔧 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.
  • 🔒 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.
  • 🆕 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.
  • 🔒 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.
  • 🔒 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).
  • 🔒 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.
  • 🔒 Pentest R91 NaN-Bypass auf accountId-Query-Param

    • R91.1 (LOW): accountId=abcparseInt('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.
  • 🐞 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).
  • 🔒 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.
  • 🆕 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.
  • 🔒 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.
  • 🔒 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.
  • 🆕 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.
  • 🐞 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).
  • 🐞 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.Gerhardbruns.gerhard.
    • Phantom--mailgroup-Parameter entfernt.
    • Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag): nichts entfernt, nur bzirks@gmx.de hinzugefügt.
  • 🔒 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.
  • 🐞 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.
  • 🔒 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".
  • 🔒 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.
  • 🐞 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.
  • 🔒 Pentest 71.171.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).
  • 🆕 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.
  • 🆕 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.
  • 🐞 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.
  • 🐞 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).
  • 🆕 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.
  • 🆕 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.
  • 🆕 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.
  • 🐞 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.
  • 🐞 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.
  • 🐞 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".
  • 🆕 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.
  • 🆕 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.
  • 🔒 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".
  • 🔒 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.
  • 🔒 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.
  • 🔒 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).
  • 🆕 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.
  • 🆕 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).
  • 🆕 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).
  • 🆕 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.
  • 🆕 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.
  • 🆕 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.
  • 🆕 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.
  • 🆕 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.
  • 🆕 „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.
  • 🆕 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).
  • 🆕 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
  • 🆕 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.
  • 🆕 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.
  • 🆕 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).
  • 🛡️ 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.
  • 🛡️ 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.
  • 🛡️ 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.
  • 🆕 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.
  • 🆕 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.
  • 🆕 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.
  • 🆕 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".
  • 🆕 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.
  • 🆕 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".
  • 🆕 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.
  • 🛡️ 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)
  • 🐛 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:secretblocked: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.
  • 🛡️ 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.).
  • 🆕 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.
  • 🆕 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 bonusinstantBonus (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.
  • 🛡️ 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 (.htmltext/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
  • 🛡️ 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: javascript: 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
  • 🛡️ 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.jsblocked://evil.com/x.js
      • file:///etc/passwdblocked:///etc/passwd
      • &#106;avascript:alert(1)blocked:alert(1)
      • &#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;OKOK
      • &lt;script&gt;bad()&lt;/script&gt;LegitLegit
      • HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
      • Echtes PDF (Magic-Bytes ok) → 200
      • 35× POST auf public-consent → Req 130 = 404, Req 31+ = 429
  • 🧹 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
  • 🛡️ 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_OVERRIDEunknown, documentPath/version gesäubert.
  • 🚨 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
  • 🛡️ 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: `OpenCRM <script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`. imprintHtml mit <h1><p> → unverändert.
  • 🐛 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.
  • 🐛 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.
  • 🛡️ 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.
  • 🚨 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.
  • 🚨 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.
  • 🚨 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
  • 🛟 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.
  • 🔐 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.
  • 🚨 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.
  • 🚨 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
  • 🚨 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.
  • 🚨 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.
  • 🛠 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.
  • 🚨 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.
  • 🚨 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.
  • 🚨 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.
  • 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.
  • 🚨 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.
  • 🚨 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.
  • 🔐 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.
  • 🔐 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.
  • 🌐 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=true1 (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.
  • 🚨 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.
  • 🛡️ 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.
  • 🔒 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).
  • ↗ 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.
  • 🔍 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.
  • 🔁 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.
  • 🛡️ 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.
  • 🐛 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.
  • 🔁 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).
  • 🚀 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).
  • 📦 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.
  • 🐛 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).
  • 🔒 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).
  • 🗃️ 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_migrationsmigrate 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.
  • 🔄 Automatische Vertrags-Status-Übergänge

    • Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit status=ACTIVE und endDate < heuteEXPIRED (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.
  • 🛡️ Security-Hardening vor Production-Deployment (10 Runden)

    • Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs: SECURITY-HARDENING.md
    • Erste 2 Runden zusätzlich ausführlich in SECURITY-REVIEW.md
    • Highlights:
      • Runde 13: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
      • Runde 4: 9 Live-IDORs (customer.*/gdpr.*) + Error-Handler
      • Runde 5: /api/uploads-Auth (DSGVO-GAU), Login-Timing, Privacy-Policy-XSS
      • Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass, Self-Grant + Existence-Disclosure
      • Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
      • Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
      • Runde 9: npm audit fix (8 Vulns weg), Audit-Chain-Rehash, keine neuen Critical-Findings → diminishing returns erreicht
      • Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection + Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
    • Deployment-Checkliste komplett (in HARDENING.md)
  • 🎉 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
  • 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
  • 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
  • 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
  • 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
  • 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")
  • Geburtsdatum + Geburtsort auch bei Firmenkunden

    • Felder werden jetzt unabhängig vom Kundentyp angezeigt
    • Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen
  • 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
  • Typspezifische Zusatzinfos in Vertragslisten

    • Strom/Gas → "Lieferadresse: ..."
    • DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
    • Mobilfunk → "Rufnummer: ..."
    • KFZ → "Kennzeichen: ..."
    • Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
  • 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
  • Zweitarif-Zähler (HT/NT) bei Strom + Verbrauchsberechnung

  • Datumsformate vereinheitlichen (01.01.2026 statt 1.1.2026)

  • Audit-Log aussagekräftig (Vorher/Nachher bei allen Änderungen)

  • Impressum + Website-Datenschutzerklärung im Kundenportal

    • Editor in Einstellungen
    • Vorschlagstexte
  • Consent-Bestätigungs-Flow per Email

    • Alle Hebel müssen gesetzt sein
    • Bestätigungsbutton + Bestätigungsemail
  • Vertragsdokumente-Upload (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)

  • Bug: Stressfrei-Email im Auftragsgenerator (funktioniert jetzt im Vertrag)

  • 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)
  • 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.)
  • Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse im Auftragsgenerator

  • Objekttyp + Lage + Lage des Anschlusses bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)

  • Bankverbindung-Fallback im PDF-Generator (neueste aktive Bankverbindung des Kunden)