# đ OpenCRM â Todo-Liste --- ## đ Offen ### Manuelle Tests (vor Release durchklicken) Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**. Einmal komplett durchlaufen vor v1.0.0-Release. ### đ SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless **Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal. **Architektur-Entscheidung:** Weg C (Instance-per-Customer) - Pro Kunde eine eigene Docker-Instanz mit eigener DB - Keine `tenantId` im CRM-Code â keine Security-Risiken durch vergessene Filter - Komplette Datenisolation (DSGVO-freundlich) - Updates können gestaffelt ausgerollt werden (erst 10% testen) - Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket" **Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) â Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf. --- **Admin-Portal (separate App, neben den CRM-Instanzen):** - Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled) - "Neuen Kunden anlegen" â Provisioning-Script - DB anlegen (Master-DB kennt die Mapping) - Docker-Container starten - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik) - Initial-Admin-Account erstellen + Einladungs-Email senden - Optional: Factory-Defaults fĂŒr Stammdaten einspielen - GoCardless-Integration (Webhook + Dashboard) - Instanz-Management: Pause/Resume bei Zahlungsproblemen - Logs & Metriken pro Instanz (optional) - Support-Bereich (Tickets? oder einfach E-Mail) --- **Abrechnung mit GoCardless (gocardless.com):** - Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success) - 30 Tage kostenlose Testphase ohne Zahlungsmittel - Nach Trial: Mandats-Erfassung â regelmĂ€Ăige Abbuchung - Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features - Webhook-Endpoint im Admin-Portal: - `payment_confirmed` â Instanz aktiv lassen - `payment_failed` â Banner im CRM, nach X Tagen pausieren - `mandate_cancelled` â KĂŒndigungs-Flow - Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen** (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen) --- **Provisioning-Flow (grober Entwurf):** 1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain) 2. Admin-Portal: Trial-Instanz starten - DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain - Einladungs-Email mit Admin-Login + Passwort-Reset-Link 3. Tag 25: Erinnerungs-Email "Deine Trial lĂ€uft bald ab" 4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren" 5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login 6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv 7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis) --- **Technische Bausteine fĂŒr spĂ€ter:** - Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs) - Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt) - Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s - Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups - Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen - Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen --- **Grobe ZeitschĂ€tzung:** - Admin-Portal (MVP): ~1 Woche - GoCardless-Integration + Webhooks: ~3-5 Tage - Provisioning-Automatisierung (Docker + Caddy): ~1 Woche - Landing Page + Checkout: ~3-5 Tage - Tests + Polishing: ~1 Woche - **Gesamt: ~3-4 Wochen** **Vorbereitung JETZT (einfach, macht spĂ€ter Arbeit leichter):** - â Factory-Defaults System (schon erledigt, hilft beim Provisioning) - â Domain/Label dynamisch per Provider (schon erledigt) - Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag) - Backup-Script robust + wiederherstellbar (haben wir schon weitgehend) --- ## â Erledigt - [x] **đ hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der von der Zeile SELBST deklarierten `hashVersion` â und die ist nicht gehasht. Angriff: `hashVersion` 2â1 setzen, die nur von V2 abgedeckten Felder aendern (`success` falseâtrue, `errorMessage` leeren, `resourceLabel` umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder nachziehen â Fehl-Login als Erfolg getarnt, Pruefung meldet âgueltigâ. Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht einmal ein Gap â dauerhaft unsichtbar. - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) â ein Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall). - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**. Neu `unexplainedGaps` â nur Luecken ohne protokollierte Loeschung sind erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich eine boeswillige Loeschung als âharmloser Gapâ tarnen konnte. - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger â **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) â **erkannt**; Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) â **erkannt**; Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen. - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze entstehen und wuerden angezeigt. Beim hier ueblichen Deploy (pull + rebuild + restart) tritt das nicht auf. - [x] **đĄïž Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18) - **R166-01 (HIGH) â Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem COMMIT. Im Fenster ReleaseâCommit las der naechste Schreiber ein noch nicht sichtbares Kettenende â zwei Zeilen am selben Vorgaenger. Meine â100 parallel â 0 Bruecheâ-Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal. Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der Lesevorgang den frisch festgeschriebenen Stand sieht. **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster: Release-vor-Commit â Fork, Zeilensperre â kein Fork. - **R166-02 (MEDIUM) â Hash deckte nur 7 Felder.** `changesBefore/After` (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht â ein Einzeledit dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) + `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2. Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/ dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig. - **R166-03 (LOW)** â âkein Cookieâ (normaler Erstbesuch) wurde als `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label âohne vorliegenden Tokenâ. Nur echte Ablehnung bleibt HIGH. - **R166-04 (LOW, pre-existing)** â `GET /retention-policies` wurde von `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der Parameter-Route, mit Warnhinweis im Code. - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein Loeschungs-Manifest (Bereich `fromId`â`toId`, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag â Luecken ausserhalb bleiben damit erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die Beweiskraft der Vergangenheit zurueckgesetzt wurde. - Verifiziert: 50 parallele Schreiber â 50/50, 0 Forks, alle V2; manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen. - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber Schluesselverwaltung/Rotation im Deployment â Entscheidung des Betreibers. - [x] **đ Audit-Pruefung: âmanipuliertâ von âLueckeâ getrennt + Retention fuer Routine-Auth** (2026-08-18) - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig unterschiedliche Befunde in einen Topf und meldete beides als âN manipulierte Eintraegeâ. Eine harmlose Verkettungsluecke sah damit aus wie ein Angriff â die Meldung war im Alltag nicht deutbar und wurde dadurch wertlos (dasselbe Muster wie beim Refresh-Rauschen). - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich veraendert â **ernst**) und `chainGaps` (Verkettung unterbrochen durch parallele Schreibvorgaenge oder geloeschte Zeilen â **meist harmlos**) erweitert. `invalidEntries` bleibt als Summe erhalten (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend eindeutig; Frontend-API-Typ nachgezogen. - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes als `Authentication / LOW`. Diese Kombination traf auf keine spezifische Regel und fiel in die Auffangregel `*` mit 3650 Tagen â das **Rauschen waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage). - Fix: Regel `Authentication / LOW` â 90 Tage. Als Migration (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit sie sowohl bestehende Installationen als auch Neuinstallationen erreicht. - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine** Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL. Ein Herabstufen âfuer eine ruhigere Listeâ wuerde still die Aufbewahrungsfrist verlaengern â daher unterlassen. - Verifiziert: Live-Test gegen Dev-DB â echte Manipulation einer Zeile (`UPDATE ⊠SET userEmail`) wird als **manipuliert** erkannt und nicht mit Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen. - [x] **đ Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18) - `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und haengten sich beide daran â Kette zerrissen (echte Bruchstellen im Bestand: 05.05./07.05.2026). - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB, wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`, da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der Verbindung zurueck in den Pool und blockiert alle Schreiber). - **Verworfener erster Ansatz â wichtig:** `SELECT ⊠FOR UPDATE` auf das Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer Kettenbruch â deshalb der Umbau auf den benannten Lock. - Verifiziert: 100 parallele Schreiber â **100/100 geschrieben, 0 neue Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7 historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen. - [x] **đĄïž Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18) - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630** Zeilen als âmanipuliertâ. Davon waren **3100 Fehlalarme** â eingegrenzt auf exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.â01.05.2026. - Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer `undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden **alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 â aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN. - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos ausloest, wird ignoriert â **echte** Manipulation ginge im LĂ€rm unter (gleiches Muster wie beim Refresh-Rauschen, [R165]). - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten; `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft, wenn die aktuelle Variante nicht passt). **Kein Rehash** â der waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes bleiben unangetastet. - Verifiziert: ungueltige Zeilen **3107 â 7** (die 7 sind echte Ketten-Brueche, siehe naechster Punkt). Adversarial gegengetestet: Manipulation an `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen akzeptiert. `tsc` gruen. - [x] **đ Audit-Log: Pfad-Matching kaputt â Auth-Actions generisch (Pentest R165-01)** (2026-08-18) - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** â jeder `/refresh` weiter `CREATE / CRITICAL / âAnmeldung erstelltâ`. Zusatzbefund: auch `/login` und `/logout` liefen als generisches `CREATE`. - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt ihn nur beim `next()`-Durchlauf wieder her â ein terminaler Handler (`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ (`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks liefen ins Leere â Fallback POSTâCREATE + Default-SensitivitĂ€t CRITICAL. Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` â FINISH `/refresh`). - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED` waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie betroffen (eigene `emit()`-Calls) â daher funktionierte das Alerting korrekt. - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream ausschlieĂlich diesen nutzen â in `determineAction`, `generateHumanLabel`, `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH` zusĂ€tzlich in die âimmer loggenâ-Ausnahme aufgenommen. - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests): `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag), `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem `endpoint`-Pfad und korrekten Labels. `tsc` grĂŒn. - [x] **đĄïž Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18) - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos `TOKEN_REFRESH`/LOW â ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter CRITICAL zu bleiben). - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zĂ€hlt `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action â `LOGIN_FAILED`) hĂ€tte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen: - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` â greift die bestehende Schwelle `â„3 TOKEN_REJECTED/5min/IP â CRITICAL` (securityAlert.service, kein Severity-Filter). Severity wie Access-Token: abgelaufen/revoked â LOW (benigne, kein Sofort-Alert), ungĂŒltige Signatur/Manipulation â HIGH (Sofort-Alert). `auth.service` reicht dafĂŒr `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. âKein Cookie" emittiert NICHT (normaler Erstbesuch). - **Audit-Triage:** fehlgeschlagener Refresh â SensitivitĂ€t HIGH statt LOW + Label âToken-Refresh abgelehnt (ungĂŒltig/abgelaufen)". Action bleibt bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login). - Verifiziert: tsx-Test â abgelaufenâLOW, manipuliert/garbageâHIGH; `tsc` grĂŒn. - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation bietet keinen Replay-Schutz (alter Token bis exp gĂŒltig), aber fail-closed nach Logout. Ggf. spĂ€ter: Refresh-Token-Jti-Blacklist / One-Time-Use. - [x] **đ Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18) - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` / âAnmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt â sah aus wie anonyme Login-Flut, war aber die eigene Session. - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`). `determineAction()` erkennt `/auth/refresh` â eigene Action; Label âSitzung verlĂ€ngert (Token erneuert)"; SensitivitĂ€t in der Middleware explizit auf **LOW** (statt Default `Authentication â CRITICAL`). - Frontend `AuditLogs.tsx`: Filter-Option âSitzung verlĂ€ngert" + dezente Badge-Farbe (slate); Typ-Union ergĂ€nzt. `anonymous` bleibt (Endpoint lĂ€uft ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) â bewusst nicht geĂ€ndert (Option 1). - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grĂŒn. - [x] **đ Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18) - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/ `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma â injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf Fremdvertrag umhĂ€ngen) oder den **PK frei setzen** (stilles 200 statt 400). MEDIUM (IntegritĂ€t; kein Cross-Tenant, staff-only, Portal 403). - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/ `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet` war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/verschlĂŒsselt) â alle Diffs leer. - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` â basePrice aktualisiert, ecd.id + contractId **unverĂ€ndert** (kein Reparenting). - [x] **đ Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint `POST /tasks` (staff-only, `contracts:update`) fĂŒr allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag â **kein Kunde â nie im Portal sichtbar** (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei contractloser Aufgabe; getAllTasks-Portal-Filter schlieĂt sie automatisch aus). - Task-Modal (Mitarbeiter): Checkbox âOhne Kunde (allgemeine Aufgabe)" â blendet Kunden-/Vertragsauswahl **und** âIm Kundenportal sichtbar" aus. Task-Liste zeigt solche Aufgaben als âAllgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link). - [x] **đ§ Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18) - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail- Providern konfigurierten Domain (oder Subdomain) liegen â man trĂ€gt so keine verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar); die Domain-PrĂŒfung greift aber bei create UND update, falls eine gesetzt wird. - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service. - [x] **⥠Energievertrag: Ankreuzfeld âKeine Bonis erwĂŒnscht"** (2026-08-18) - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`). Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail. - [x] **đ MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) â im AddressModal (nur Lieferadresse) pflegbar. - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte (ELECTRICITYâStrom, GASâGas) die MaLo der gewĂ€hlten Lieferadresse zeigt (mit Copy + âdort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der Adresse. - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent) **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` â jeweilige Lieferadresse, StromâmaloIdElectricity / GasâmaloIdGas). Verifiziert. - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156- Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address- Whitelist gefallen â die EigentĂŒmer-Sektion speicherte seit `cb21a2c` nicht mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus). - [x] **đ Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt â konsistent zur R156-HĂ€rtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/ `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind damit **nicht** mehr per Form-Update setzbar (Pfade nur noch ĂŒber die Upload-Endpunkte). - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem unvollstĂ€ndigen `ContractCreateData`-Typ!) â dabei fielen 4 echte, vom Formular gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wĂ€ren: `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`, `nextReviewDate`. - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert. - [x] **đïž Neuer Vertragsstatus âGekĂŒndigt / bestĂ€tigt" + KĂŒndigungs-Workflow** (2026-08-13) - Bisheriges **âGekĂŒndigt"** umbenannt in **âGekĂŒndigt / BestĂ€tigung abwarten"** (Status `CANCELLED`) â wird jetzt automatisch gesetzt, sobald ein **KĂŒndigungsschreiben** (`cancellationLetterPath`) hochgeladen wird (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestĂ€tigt). - **Neuer Status âGekĂŒndigt / bestĂ€tigt"** (`CANCELLED_CONFIRMED`) â automatisch, sobald ein **KĂŒndigungsbestĂ€tigungsdatum** vorliegt (per BestĂ€tigungsdokument, das das Datum fĂŒllt, ODER manuell) + Vertragsende = KĂŒndigungsdatum. Hebt auch aus âBestĂ€tigung abwarten" hoch. - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration `20260813200000_contract_status_cancelled_confirmed` (idempotentes `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter Logik nur bei vorhandener BestĂ€tigung gesetzt) â `CANCELLED_CONFIRMED`. - **Cockpit-Semantik mitgewandert:** Fristen-Skip + âbeendet" (Schlussrechnung) gelten jetzt fĂŒr `CANCELLED_CONFIRMED` (nicht mehr das reine âabwarten"); âKĂŒndigungsbestĂ€tigung fehlt"-Warnung greift dadurch weiter fĂŒr CANCELLED. `CANCELLED_CONFIRMED` in Ladeliste + KĂŒndigungsbestĂ€tigungs-Filter aufgenommen. - **Frontend:** Labels/Farben/Status-ErklĂ€rungen + Status-Dropdown in ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail (CANCELLED = orange âabwarten", CANCELLED_CONFIRMED = rot). Verifiziert: SchreibenâCANCELLED, BestĂ€tigungâCANCELLED_CONFIRMED+Enddatum. - [x] **đ Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13) - Controller reichten rohen `req.body` an Prisma durch â `customerId` (Owner) und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber IntegritĂ€tsschwĂ€che â und mit `cardNumber` liegt Finanz-PII drauf). - **Fix:** explizite Feld-Whitelist im Service (create+update) fĂŒr **BankCard, Address, IdentityDocument** â nur benannte Felder gehen an Prisma, kein `...data`/`req.body`-Spread mehr. ZusĂ€tzlich Controller-`pickBankCardFields` fĂŒr saubere Audit-Logs (keine Phantom-EintrĂ€ge injizierter Keys). - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}` â id+customerId **unverĂ€ndert**, nur cardNumber gesetzt, Fremdfelder ignoriert. - [x] **đȘȘ Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13) - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint. Eingabefeld âKartennummer" im Bankkarten-Modal (Kundenakte) ergĂ€nzt. - **Vertragsansicht (ContractDetail)** â Karten âBankkarte"/âAusweis" zeigen zusĂ€tzlich (jeweils mit Copy-Button, nur wenn gesetzt): - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum vom Kunden** - **Vertrag bearbeiten (ContractForm)** â unter den Bankkarte-/Ausweis- Dropdowns dieselben Detailfelder der aktuell gewĂ€hlten Karte/Ausweis (Copy-Buttons). Selects dafĂŒr je in eigenem `