Viele Vertriebsplattformen vergeben eigene Nummern, die nicht mit
denen des Endanbieters identisch sind. Zwei neue optionale Felder
unter "Anbieter & Tarif".
- Schema: Contract.customerNumberAtSalesPlatform +
contractNumberAtSalesPlatform, Migration mit IF NOT EXISTS.
- ContractForm: zwei neue Inputs direkt unter den entsprechenden
Provider-Feldern.
- ContractDetail: eigene Zeilen mit CopyButton.
- Audit-Log-Mapping + Renewal-Copy + XSS-Strip-Whitelist mitgezogen.
- Bonus: contractNumberAtProvider war im Renewal-Copy und Audit-
Label-Mapping fehlend – mitkorrigiert.
Hardware-Plastikkarte vs. eSIM-Profil ist eigene Eigenschaft – eSIM
kann sowohl Hauptkarte als auch Multisim sein, deshalb dritter
Toggle statt entweder/oder.
- Schema: SimCard.isEsim Boolean default false, Migration mit
IF NOT EXISTS.
- Backend: vier SimCard-Schreibpfade in contract.service.ts (Create,
Update, Follow-Up, Renewal).
- UI: dritte Checkbox in ContractForm zwischen Hauptkarte und
Multisim. ContractDetail zeigt blauen eSIM-Badge.
57.7 (Consent-Hash ohne TTL):
- Neues Feld Customer.consentHashExpiresAt + Migration
20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten
bekommen NOW()+30d als Default, damit frische Versand-Links nicht
sofort sterben.
- TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service.
- getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw.
klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in
der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen").
- ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen
ist – Versand neuer Links bleibt friction-frei.
- consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit
der Standard-Customer-Endpoint kein Workflow-Info leakt.
57.8 (Zip-Slip / Zip-Bomb):
- Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS-
Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente
schon im Original-Namen (vor path.resolve).
- Zip-Slip-Check auf path.relative umgestellt – robuster als
startsWith(prefix + sep), insbesondere bei nested Resolution.
- Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed-
Limit; bei Überschreitung Abbruch mit klarer Meldung.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bisher steht in PhoneNumber.phoneNumber die kombinierte Nummer
("04264 836975"). Die Wechselauftrag-PDFs splittten heuristisch
auf Vorwahl/Anschluss, was bei Sonderformaten daneben ging.
Schema: PhoneNumber.areaCode String? (optional, Bestandsdaten
werden beim nächsten Edit nachgepflegt). Migration
20260601200000_phone_area_code mit IF NOT EXISTS.
ContractForm: aus "Rufnummer" werden zwei Felder – "Vorwahl" und
"Rufnummer". Beim Speichern sendet das Frontend areaCode separat
UND die kombinierte phoneNumber (für Listen/Suchen weiter
unverändert). Beim Edit-Load wird areaCode bevorzugt; falls leer,
splittet die UI heuristisch und prefillt beides – User kann
korrigieren und beim Speichern wird der saubere Wert persistiert.
PDF-Template-Service: phoneAreaCode[N] und phoneLocal[N]
verwenden jetzt primär den gespeicherten areaCode aus der DB
(verlässlich), Heuristik nur als Fallback für Altbestand. Die
Template-Variablen-Liste war bereits korrekt definiert, jetzt
ist die Datenquelle solide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei Firmenverträgen (Vertragsinhaber = Firma, Nutzer = Mitarbeiter)
und Familienverträgen (Inhaber = Eltern, Nutzer = Kind) brauchten
wir ein Feld, das den tatsächlichen Nutzer der SIM-Karte erfasst.
Backend: SimCard.cardUser (String?, optional), Migration
20260601100000_sim_card_user mit IF NOT EXISTS. Im Service durch
Create + Update propagiert.
Frontend: Input "Kartennutzer" pro SIM-Karte in ContractForm
(eigene Zeile oberhalb der technischen Felder Rufnummer/SIM-Nr/
PIN/PUK). In ContractDetail wird der Nutzer als "Nutzer: <Name>"
neben den Hauptkarte/Multisim-Badges angezeigt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Meter.predecessorMeterId (Self-Relation) + Migration
20260530140000_meter_predecessor mit IF NOT EXISTS
- createMeter akzeptiert optional successorOf:
{predecessorMeterId, installedAt?, finalReadingPrevious?}.
Vorgänger wird validiert (gleicher Kunde + Typ); alle Verträge
mit dem Vorgänger als aktuellen Zähler werden analog zu
addSuccessorMeter automatisch auf den neuen Zähler umgestellt
(ContractMeter-Eintrag mit removedAt/finalReading für den
Vorgänger, neuer ContractMeter mit installedAt + nächster
Position, energyDetails.meterId aktualisiert)
- MeterModal: Checkbox "Als Folgezähler deklarieren" + Dropdown
Vorgänger + Wechseldatum + Endstand. Typ/Tarifmodell/Adresse
werden vom Vorgänger übernommen und disabled. Info-Banner über
Vertragsauto-Update
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration
20260530100000_meter_address mit IF NOT EXISTS
- Service erzwingt beim Create: Lieferadresse vorhanden + zum
Kunden gehörig + Typ DELIVERY_RESIDENCE
- MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled
ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen
"nicht zugeordnet – bitte über Bearbeiten nachpflegen"
- ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse;
deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei
Auswahl Toast-Warnung wegen möglichem Altvertrag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas-
Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel
+ Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden
müssen.
Migration 20260524100000_split_energy_bonus:
- ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus
- bestehende `bonus`-Werte → instantBonus (Annahme: Sofort)
- DROP COLUMN IF EXISTS bonus
UI:
- ContractForm zeigt zwei Input-Felder
- Detail-Ansicht zeigt beide einzeln + Gesamtbonus
- Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive
Jahreskosten
Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation
liefert instantBonus, newCustomerBonus, totalBonus.
PDF-Template: drei neue Variablen energyDetails.instantBonus,
.newCustomerBonus, .totalBonus.
Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert,
GET liefert zurueck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backup-Seite zeigt zwei neue Log-Panels: links Backup-Erstellung,
rechts Backup-Wiederherstellung. Jeder Eintrag mit ✓/✗-Status,
Summary, Timestamp + User. Klick öffnet Modal mit vollständigem
Verlauf – alle console.log/error/warn/info-Zeilen werden während
der Operation in einen Puffer mitgefangen und im fullLog-Feld
persistiert. Auto-Refresh alle 5s.
Persistenz: neue Tabelle BackupLog mit Migration
20260519100000_backup_log (CREATE TABLE IF NOT EXISTS für Re-Deploys
auf DBs mit Vorab-db-push). fullLog auf 1 MB gecappt.
Endpoints (settings:update):
- GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
- GET /api/settings/backup-logs/:id
EBUSY-Fix: Der neue Log-Verlauf hat sofort einen alten Bug
sichtbar gemacht. backup.service.restoreBackup rief
deleteDirectory(UPLOADS_DIR) auf, dessen finales rmdirSync auf
/app/uploads ein EBUSY warf – das Verzeichnis ist im Container ein
Bind-Mount und lässt sich nicht aushängen. Fix: neuer Helper
emptyDirectory() löscht nur die Inhalte, das Verzeichnis bleibt
stehen.
Live-verifiziert: 4867 Datensätze + 1 Datei in 13.2s
wiederhergestellt; Log-Modal zeigt den vollständigen Verlauf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beim Audit der Container-Pipeline zwei Bugs gefunden:
1) backend/docker-entrypoint.sh (= der wirklich ausgeführte Entrypoint
laut Dockerfile) ruft jetzt das Cleanup-Script auf. Der Cleanup-
Aufruf hing bisher fälschlich in docker/entrypoint.sh – ein
alternatives Setup, das von der Standard-Compose-Konfiguration
NICHT genutzt wird. Folge: das Cleanup ist auf prod nie gelaufen.
2) Migration 20260516173552_portal_password_must_change nutzt jetzt
`ADD COLUMN IF NOT EXISTS`. Auf prod-DBs, die zwischen den Runden
per `prisma db push` updated wurden (z.B. weil der erste Build
mit `db push` provisioniert war), existiert die Spalte bereits.
Ohne IF NOT EXISTS würde migrate deploy beim Hochziehen einer
neueren Version mit "Duplicate column" abbrechen.
MariaDB ≥ 10.0.2 + MySQL ≥ 8.0.27 unterstützen IF NOT EXISTS für
ALTER TABLE ADD COLUMN – beides ist in unserer Compose-Konfig
abgedeckt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KRITISCH – Privilege Escalation:
POST /api/developer/setup war ohne Auth erreichbar und konnte
developer:access der Admin-Rolle hinzufügen → volle DB-Kontrolle
via /developer/*-Routen. Endpoint ersatzlos entfernt; manuelles
Setzen geht über prisma/add-developer-permission.ts (CLI).
HOCH – Fehlende Migration auf Prod:
portalPasswordMustChange war im Code, aber prod-DB hatte die
Spalte nicht → jeder Kunden-Login warf Prisma-Schema-Error → DoS.
Root Cause: db push statt migrate dev während Entwicklung →
kein Migration-File im Repo. Fix: handgenerierte Migration
20260516173552_portal_password_must_change/migration.sql, lokal
mit migrate resolve --applied registriert, durch shadow-DB-Reset
verifiziert. entrypoint.sh führt migrate deploy bereits aus.
MITTEL – Prisma-Internals-Leak im Login-Error:
error.message wurde 1:1 an den Client gegeben → bei DB-Schema-
Fehlern leakten Tabellen- und Spaltennamen. Whitelist-Filter
safeLoginError() in auth.controller.ts: nur 'Ungültige
Anmeldedaten' und 'E-Mail und Passwort erforderlich' werden
durchgereicht, alles andere wird zu generischem 'Anmeldung
fehlgeschlagen' maskiert. Original landet im Server-Log.
Live-verifiziert:
- POST /api/developer/setup → HTTP 404
- Falsches Customer-PW → 'Ungültige Anmeldedaten' (keine Internals)
- Spalte testweise gedropped → 'Anmeldung fehlgeschlagen' (generisch),
Original-Message nur im Server-Log
- Shadow-DB-Reset + migrate deploy → Spalte korrekt erzeugt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`db push --accept-data-loss` konnte bei Schema-Änderungen still Daten verlieren
(Renames, Type-Changes, NOT NULL ohne Default). Umstellung auf versionierte
Migrations:
- 0_init aus aktuellem Schema generiert (alte gedriftete Migrations entfernt)
- entrypoint: Auto-Baseline für bestehende DBs ohne `_prisma_migrations`,
dann `migrate deploy` (idempotent, kein Daten-Loss)
- npm run schema:sync: legt automatisch eine Migration mit Zeitstempel an
(`prisma migrate dev --name auto_<ts>`)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>