# 📋 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] **đŸ›Ąïž 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 `
` gewrappt (Grid-Alignment). - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/ customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder. - [x] **📄 PDF-Viewer-Modal fĂŒr Bankkarte-/Ausweis-Dokument** (2026-08-13) - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `