Compare commits

..

174 Commits

Author SHA1 Message Date
duffyduck aa0900410b Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)
31.1 Stored XSS in Vertragsfeldern:
providerName, tariffName, priceFirst12Months, priceFrom13Months,
priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>,
<svg/onload>, <img onerror>, javascript:, HTML-Entities) an und
lieferten sie 1:1 an Portal-User zurueck.

Fix: rekursiver sanitizeContractBody()-Walker im contract.controller,
strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip +
URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen
HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die
sanitisierte Variante, sonst Audit ↔ DB-Drift.

31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere):
requireCustomerAccess short-circuitete auf customers:read. Portal-
User haben aber genau diese Perm im JWT (für eigene Daten) – damit
kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/
Stressfrei-Emails von Kunde 3.

Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene
IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle
sechs requireCustomerAccess-Routes dicht. Defense-in-Depth:
zusätzlicher canAccessCustomer-Call in
stressfreiEmail.getEmailsByCustomer analog zum POST-Handler.

Live-verifiziert auf dev:
- Portal-User 1 → Customer 3: alle 6 Routes 403
- XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:38:16 +02:00
duffyduck 897abc7b21 Datenschutzerklärung als unterschreibbare PDF-Vorlage
Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf
generiert eine PDF mit:
- Titel
- Personalisiertem Kopf (Name / Firma + Kundennummer + Datum)
- Voller Datenschutzerklärung (HTML → Text)
- Einwilligungsklausel
- Unterschriftenblock (Ort/Datum links, Unterschrift rechts,
  zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem
  Kundennamen)

Auth: customers:read + canAccessCustomer. Filename:
"datenschutzerklaerung-<kundennummer>.pdf".

Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt
direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben"
– Ausdrucken, unterschreiben lassen, scannen, wieder hochladen.

Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende,
2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift-
Linien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:30:11 +02:00
duffyduck 69a52ffe03 README: Admin-Initial-Passwort ist seit Pentest-12 zufällig
Die README versprach weiterhin "admin@admin.com / admin" als Default,
aber Pentest Runde 12 hat das hardcoded "admin" entfernt
(Komplexitäts-Policy-Verletzung). Der Seed generiert jetzt ein
28-Zeichen-Zufallspasswort und schreibt es einmal nach stdout.

Aktualisiert:
- Quick-Start-Header: Hinweis statt direktes Passwort
- "Erste Inbetriebnahme"-Block: docker-logs-Befehl + SEED_ADMIN_PASSWORD-Alternative
- "Erster Login"-Sektion: vollständige Anleitung inkl. Beispiel-Ausgabe
- "Production-Deployment"-Checkliste: aktualisiert
- .env.example: SEED_ADMIN_PASSWORD-Block dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:56:55 +02:00
duffyduck 771f46d2ac Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Input
Feature 1 – Kunden-Schnellansicht:
Info-Icon neben dem Kundenlink im Vertragsdetail oeffnet ein Modal
mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
Gruendungsdatum, Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail,
Steuer-/Handelsregisternr). Jedes Feld hat einen Copy-Button.
Lazy-Fetch via customerApi.getById, staleTime 30s.

Feature 2 – Cent/Euro-Doppel-Input:
Neben dem €/kWh-Arbeitspreis-Feld jetzt ein zweites ct/kWh-Feld.
Bidirektional gekoppelt – Tippen in € aktualisiert ct (×100),
Tippen in ct aktualisiert € (÷100). Backend speichert weiterhin
nur den Euro-Wert; Cent ist reine UI-Hilfe. Float-Rausch-Schutz
verhindert "0.25 → 25.0000000000004". Greift fuer unitPrice und
unitPriceNt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:41:37 +02:00
duffyduck 20d42c5270 Energie-Bonus aufgeteilt in Sofort + Neukunden
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>
2026-05-24 14:27:54 +02:00
duffyduck 92c3b0dc95 docker-compose: SSRF_BLOCK_PRIVATE_IPS-Env durchreichen
Damit der Flag aus der .env auch im Container ankommt – Default
false (on-prem-kompatibel), Cloud-Deploys setzen in der .env
SSRF_BLOCK_PRIVATE_IPS=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:18:55 +02:00
duffyduck 83cd737e81 README: SSRF_BLOCK_PRIVATE_IPS dokumentiert (Cloud-Deploy)
Neue Section "Deployment-Modus: On-Prem vs. Cloud" im Production-
Deployment-Block. Erklaert, warum On-Prem-Default private IPs
erlaubt (Plesk/Dovecot lokal) und wann der Flag fuer Cloud-Deploys
auf true gesetzt werden soll. Cloud-Metadata-Endpoints sind
unabhaengig vom Flag immer geblockt.

.env.example: SSRF_BLOCK_PRIVATE_IPS=false als Default mit Block-
Kommentar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:17:05 +02:00
duffyduck a95aa384a2 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 prueft,
konnte eine als application/pdf deklarierte .html-Datei auf Disk
landen – Express liest beim Senden den Content-Type aus der Extension
(text/html), Browser haette gerendert → Stored XSS.

Fix: Content-Disposition: attachment + safe filename. Browser laedt
jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost
ist gering (PDF-Preview offnet halt aus dem Download-Ordner).
X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt.

30.14 SSRF Private-IP-Block opt-in (INFO):
ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil
On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer
Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag
SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle
privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/
ip6-localhost. Default off (on-prem-kompatibel).

Live-verifiziert auf dev:
- Download-Header: Content-Disposition: attachment + safe filename
- Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen,
  169.254.169.254 (Cloud-Metadata) weiter geblockt
- SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt,
  8.8.8.8 (legitim) durchgelassen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:14:59 +02:00
duffyduck 9cf8c505af Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).

29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.

29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.

29.3 Zero-Width-Joiner:
"j​av​ascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.

28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).

29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.

29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.

Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:47:44 +02:00
duffyduck 65ec07e274 Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
28.1 URI-Schema unvollstaendig:
DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js"
und "file:///etc/passwd" wurden vorher in companyName akzeptiert.

28.2 HTML-Entity-Decoding-Bypass:
stripHtml() lief direkt ueber den Roh-String, "&#106;avascript:",
"&#x3C;script&#x3E;" und "&lt;script&gt;" umgingen die Regex.
decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) +
gaengige named entities VOR dem Tag-/URI-Strip.

28.3 Vollmacht-Upload Magic-Byte-Check:
multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als
application/pdf durch. uploadAuthorizationDocument liest jetzt die
ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400.

28.4 Rate-Limit auf /api/public/consent:
30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit-
UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System
mit Audit-Log- und Mail-Spam belasten.

Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime
Eingaben unangetastet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:46:15 +02:00
duffyduck 8e48d3b432 Pentest 2026-05-20 LOW/INFO Sammelfix
27.1 Path-Traversal-Strings in DB:
- cleanupConsents validierte documentPath zuvor nur per stripHtml,
  ließ "../../../etc/passwd" durch. Neuer isValidDocumentPath-Check
  akzeptiert nur "/uploads/<safe>", alles andere → NULL.
- cleanupDocumentPaths scannt fünf weitere Tabellen (BankCard,
  IdentityDocument, Invoice, RepresentativeAuthorization nullable;
  ContractDocument NOT NULL → nur Report).

Orphaned User:
- reportOrphanedUsers warnt beim Container-Start vor User ohne
  Rollenzuordnung (im Permission-System unsichtbar). Löschen nicht
  automatisch wegen False-Positive-Risiko.

Seed-PW-Policy:
- generateInitialPassword() nutzte Math.random() (vorhersagbar).
  Jetzt crypto.randomInt() für Pick + Fisher-Yates-Shuffle.

PUT /users/:id mit permissions / password:
- Vorher silent-drop durch Whitelist + HTTP 200, Caller glaubte
  faelschlich, Werte waeren uebernommen. Jetzt HTTP 400 mit
  konkreter Hilfe-Message.

/api/health ohne Auth:
- Pentest-Befund INFO: bewusst so, Container-Healthcheck und
  Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
  {status,timestamp} – keine Version, kein DB-Status, kein
  Info-Leak. Comment im Code dokumentiert die Entscheidung.

Live-verifiziert auf dev: alle fuenf Findings durchgetestet,
jeweils mit dirty Input → erwartete Sanitization/Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:49:06 +02:00
duffyduck adc3b70492 Pentest 2026-05-20 MEDIUM+LOW Follow-ups
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 status aus Body, source server-seitig auf "portal"
hardcoded, documentPath/version bleiben NULL (werden dediziert
vom Authorization-Upload server-seitig gesetzt). Whitelist
ALLOWED_CONSENT_SOURCES für source-Werte. grantAuthorization
(Admin) erzwingt die Whitelist ebenfalls; notes läuft jetzt
durch stripHtml.

LOW – javascript:-URI in companyName:
stripHtml() entfernte HTML-Tags, ließ aber javascript:/data:/
vbscript:-Schemata stehen. companyName="javascript:alert(1)"
hätte in <a href={companyName}> aktiv werden können.

Fix: stripHtml ersetzt jene Schemata mit "blocked:" – legitimer
Text bleibt unangetastet, das Schema wird unschädlich.

LOW – documentPath ohne Validierung:
Bereits durch obigen Consent-Fix erledigt; Cleanup-Pass strippt
zusätzlich vorhandene dreckige Pfade.

cleanup-xss-and-mass-assignment.ts: neue cleanupConsents() läuft
beim Container-Start, normalisiert source per Whitelist auf
"unknown" + stripHtml über version/documentPath.

Live-verifiziert auf dev (alle drei Payloads geblockt + Cleanup
auf dirty DB greift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:13:19 +02:00
duffyduck bf7afdd9a6 Pentest KRITISCH: Backup-Restore braucht 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, sodass ein versehentlicher
Re-Fire (Doppelklick, Browser-Tab-Replay, eingeloggter Admin auf
bösartiger Drittseite) die komplette DB stillschweigend
überschreiben konnte.

Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
{ "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Der
Magic-String ist absichtlich ein einzigartiges Token (kein Boolean),
damit kein Auto-JSON-Tooling/Replay aus Versehen triggern kann.

Frontend-API-Client setzt das Token im Body automatisch – der
existierende Bestätigungs-Dialog im UI bleibt UX-mäßig unverändert.

Live-verifiziert:
- leerer Body → 400
- { confirm: "ja" } → 400
- { confirm: "RESTORE-BESTAETIGT" } → 200, Restore läuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:05:00 +02:00
duffyduck b3a6620da6 XSS-Sanitization für AppSettings (companyName & Co)
Pentest-Befund (MEDIUM): companyName und weitere Plain-Text-Setting-
Keys nahmen via PUT /api/settings/:key XSS-Payloads wie
<img src=x onerror=alert(1)> ungefiltert entgegen. Nur Admin
triggerbar, aber E-Mail-Templates/PDF-Generatoren hätten den Wert
unescaped rendern können.

Fix in appSetting.service.ts: sanitizeSettingValue(key, value)
strippt HTML außer für die expliziten Editor-Keys (imprintHtml,
privacyPolicyHtml, authorizationTemplateHtml,
websitePrivacyPolicyHtml). Greift in updateSetting + updateSettings.

cleanup-xss-and-mass-assignment.ts bereinigt bestehende dreckige
Werte beim Container-Start (idempotent).

Live-verifiziert auf dev:
- PUT companyName="<img onerror=alert(1)>OpenCRM<script>alert(2)</script>"
  → DB: "OpenCRM"
- Bulk-PUT mit XSS auf companyName + defaultEmailDomain → gestrippt
- imprintHtml mit "<h1>...<p>" → unverändert (HTML-allowed)
- Cleanup-Skript auf dirty value: "EvilCo" statt mit Tags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:49:19 +02:00
duffyduck 8ee5c9b07a Rollen+Permissions-Sync beim Container-Start
Folge-Fix für die DSGVO-Menü-Sache. Settings.tsx hatte ich auf
audit:read || gdpr:admin erweitert, aber auf bestehenden
Installationen läuft der prisma-Seed nicht (nur auf leeren DBs).
Wer das System früher installiert hat, hat die DSGVO-Rolle ohne
audit:read in der DB – das JWT enthielt die Perm dann nie, und der
neue Settings.tsx-Check blieb wirkungslos.

Neues Skript prisma/sync-roles.ts läuft idempotent bei jedem
Container-Start: upserts Permissions-Katalog + syncRolePermissions
für Admin, Developer, DSGVO, Mitarbeiter (R/W + R/O), Kunde.
Stammdaten, User und Verträge werden NICHT angefasst – sicher auf
prod.

Live-verifiziert: nach `DELETE audit:read FROM RolePermission`
liefert der nächste Lauf "+1 Permissions an Rolle #27", DSGVO ist
wieder komplett.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:41:39 +02:00
duffyduck 37df8c0c4a Backup-Operations-Log + EBUSY-Fix beim Restore
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>
2026-05-19 11:53:04 +02:00
duffyduck 95541e8ac4 fix: DSGVO-Rolle sieht Menüpunkte in Einstellungen wieder
System-Block in Settings.tsx war komplett in
hasPermission('settings:update') gewickelt. DSGVO-User haben aber nur
audit:* und gdpr:* Perms – kein settings:update – und sahen damit
weder DSGVO-Dashboard, Datenschutzerklärung, Vollmacht-Vorlage,
Impressum, Website-Datenschutz, E-Mail-Versandlog noch Audit-Log.

Outer-Check auf (settings:update || audit:read || gdpr:admin)
erweitert. Innere Per-Card-Checks bleiben unverändert, sodass jeder
User nur das sieht, wofür er Perms hat.

Backend-API mit reinem DSGVO-Token gegengetestet: alle 9 Endpoints
liefern 200 – Routes hatten kein Permission-Problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:44:48 +02:00
duffyduck 6ae815393e backup-restore: vollständiger Stack im Server-Log + lesbare UI-Details
Der globale ORM-Leak-Sanitizer ersetzt error/details, die TypeError/
"Cannot read properties of undefined" enthalten, durch "Operation
fehlgeschlagen". Das ist richtig für Auth-Endpoints, blockt aber bei
legitimen Admin-Operationen wie Restore die Diagnose-Info.

Backend (restoreBackup):
- console.error mit "[restore]"-Prefix loggt Backup-Name + vollen
  Stack ins Server-Log. Per `docker logs opencrm-app | tail -200`
  einsehbar.
- makeRestoreErrorReadable() strippt Stack-Frames, rephrased
  bekannte JS-Runtime-Marker ("TypeError:" → "Code-Fehler:",
  "Cannot read properties of undefined (reading 'x')" → "Wert
  fehlt: x") + cuttet auf 500 Zeichen. Dadurch passiert die
  Meldung den globalen Sanitizer und landet lesbar im Response.
- Response bekommt zusätzliches `hint`-Feld mit dem konkreten
  docker-Befehl.

Frontend (DatabaseBackup):
- extractError liefert jetzt strukturiertes Objekt
  {headline, details, hint} statt nur String.
- Dialog: Headline fett, details in Mono-Box, hint italic darunter.
- Toast: Headline + details zusammen, 10s sichtbar.

Live-verifiziert:
- Bad name → "Backup nicht gefunden" (klare Meldung)
- Echtes Backup → "4859 Datensätze wiederhergestellt" als Toast,
  Dialog zu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:30:13 +02:00
duffyduck 06c427ee39 backup-restore: Toast bei Erfolg, ausführliche Fehlermeldung im Dialog
Vorher: nach Klick auf "Ja, wiederherstellen" passierte UI-seitig
einfach … nichts Sichtbares außer dass der Dialog (irgendwann) zuging.
Bei einem 500er-Fehler blieb der Dialog offen ohne erkennbare
Begründung – der User dachte, die Aktion sei nicht durchgelaufen,
und klickte teils nochmal.

Jetzt:
- Erfolg → Dialog zu, grüne Toast-Meldung mit der Backend-Response
  ("X Datensätze und Y Dateien wiederhergestellt"), 6s sichtbar.
- Fehler → Dialog bleibt offen mit roter Detail-Box drinnen,
  Backend-Felder error + details zusammengefügt, plus
  Toast-Notification 8s. Button-Label wird zu "Erneut versuchen",
  Sekundär-Button zu "Schließen".
- Beim Schließen wird mutation.reset() aufgerufen, damit beim
  nächsten Öffnen keine alten Fehler dranhängen.

extractError-Helper ist allgemein – kann später für andere
Backup-Aktionen wiederverwendet werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:16 +02:00
duffyduck 2cb6f172c9 Login-Rate-Limit pro (IP + Email)-Tupel + PUT /portal verbietet password
Login-Rate-Limit:
Bucket-Key jetzt `${ip}|${email-lowercase}`, ein Limiter (10/15min).
Vorher IP-only oder Email-only führten beide zu Problemen:
- IP-only: Proxy-Wechsel umgeht Sperre auf Account-Ebene
- Email-only: Familie hinter NAT (Max vertippt sich → Nina blockiert),
  Account-Lockout-DoS möglich
- Tupel: Max gesperrt, Nina von gleicher IP weiterhin frei, Max von
  anderer IP auch noch, eigener Account bleibt erreichbar.

Implementation:
- middleware/rateLimit.ts: keyGenerator → ip|email
- routes/auth.routes.ts: nur ein loginRateLimiter am /login + /customer-login
- controllers/rateLimitAdmin.controller.ts: Listing als (IP, Email)-
  Tupel, Reset nimmt ipAddress + optional email. Audit-resourceId =
  ip|email (gleich wie Bucket-Key) → Listing kann Reset herausfiltern.
- frontend/RateLimits.tsx: Tabelle mit IP- und Account-Spalte,
  Reset-Button schickt beides.

PUT /customers/:id/portal:
Body-Felder password/portalPassword/portalPasswordHash/
portalPasswordEncrypted werden explizit mit 400 abgelehnt. Vorher
wurden sie silent ignoriert + HTTP 200, was den Client glauben ließ,
das PW sei gesetzt. Hinweis im Error-Body zeigt auf den dedizierten
POST /portal/password-Endpoint.

Live-verifiziert:
- 11x falsch max@x.de → 429
- Nina/Admin von gleicher IP → durch
- Reset (IP, max) → max wieder 401 statt 429
- PUT /portal {password:"abcd"} → 400 "Felder nicht erlaubt"
- PUT /portal ohne password → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:18:59 +02:00
duffyduck 0f2dc44e45 cleanup: hacker@-Marker raus (legitime Nachnamen "Hacker")
Familie Hacker / Kunden mit "Hacker" als Nachnamen nutzen reichlich
hacker@familie-hacker.de & Co. Das `^hacker@`-Pattern hätte alle
fälschlich als Pentest-Marker erkannt. Raus damit.

Verbleibende Marker reichen aus:
- ^attacker@, ^pentest@, @evil.
- <script, onerror=, javascript:
- SQL-Injection-Pattern, Path-Traversal

Verifiziert: hacker@familie-hacker.de geht durch, attacker@evil.de
wird weiterhin erkannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:36 +02:00
duffyduck b87a2a3d08 fix: Cleanup im echten Entrypoint + idempotente Migration
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>
2026-05-18 20:24:33 +02:00
duffyduck 48fe69cdab Security-Hardening Runde 17: JWT-TTL + Pentest-Marker-Detection
Pentest Runde 17:

21.1 Access-Token TTL war 7 Tage statt 15min:
docker-compose.yml und .env.example standen schon richtig auf 15m
als Default. Die alten Beispiel-.env-Files (backend/.env.example,
docker/.env.example) hatten noch die alte Konvention "7d". Beide
auf 15m korrigiert + explizites JWT_REFRESH_EXPIRES_IN=7d ergänzt.
Auf prod muss die echte .env entsprechend angepasst werden.

17.5 Alte Pentest-Daten in DB:
Cleanup-Script erweitert um Pentest-Marker-Erkennung:
- Email-Pattern: ^hacker@, ^attacker@, ^pentest@, @evil\.
- XSS-Marker: <script, onerror=, javascript:
- Sonstige: SQL-Injection, Path-Traversal

Bewusst eng gefasst (Marker MUSS am Email-Anfang stehen), damit
legitime Kunden wie "stefanhacker@gmx.de" nicht als Pentest-Daten
durchgehen.

Default: nur warnen + Records auflisten. Opt-In via
CLEANUP_PURGE_PENTEST=true löscht die markierten Customer/User.

Live-verifiziert:
- stefanhacker@gmx.de (echt) → durchgelassen
- hacker@evil.de (Pentest) → erkannt + Warnung
- Mit Purge-Env → gelöscht

18.4 Klartext-Portal-PW-Abruf:
Bewusst drin gelassen (Admin-UI-Komfort). Endpoint ist mit
customers:update-Permission gated + Audit-Log (READ →
PortalPassword) – kein Bypass-Risiko, nur explizite Audit-Pflicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:06:03 +02:00
duffyduck 373fab8e83 Security-Hardening Runde 16: KRITISCH – Update-Responses sanitisieren
Pentest Runde 15:

20.3 KRITISCH:
PUT /customers/:id gab portalPasswordHash (bcrypt $2a$12$…) im
Response zurück. updateCustomer reichte das rohe Service-Output
ohne sanitize-Aufruf durch.

20.4 HOCH (gleiche Klasse):
PUT-Response leakte portalPasswordResetToken, portalPasswordMustChange,
consentHash, portalTokenInvalidatedAt.

Fix:
- updateCustomer + createCustomer rufen sanitizeCustomer bzw.
  sanitizeCustomerStrict je nach customers:update-Permission.
- updateContract + createContract + createFollowUp + createRenewal
  analog mit sanitizeContract / sanitizeContractStrict je nach
  isCustomerPortal.
- portalPasswordMustChange + portalTokenInvalidatedAt von
  PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS
  hochgezogen → greift auch in normaler sanitizeCustomer
  (Admin-Sicht).

Live-verifiziert:
- Admin PUT /customers/3 → 0 Leaks von Hash/Token/Expires/MustChange/
  consentHash/TokenInvalidatedAt; portalPasswordEncrypted bleibt
  für Admin sichtbar (UI-Workflow, separater Endpoint mit Audit)
- POST /customers → 0 Leaks
- Portal-User GET /customers/3 → 0 Leaks auch bei
  portalPasswordEncrypted/notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:43:45 +02:00
duffyduck 5ffd1a4d2c fix: prisma.ts baut DATABASE_URL aus DB_*-Vars (für docker exec)
docker-compose reicht DB_USER/DB_PASSWORD/DB_HOST/DB_NAME an den
Container weiter, aber DATABASE_URL wird erst beim Container-Start
im entrypoint.sh aus diesen Komponenten zusammengebaut und exportiert.
`docker exec` startet eine neue Shell, die das exportierte
DATABASE_URL nicht erbt → ./scripts/admin-rescue.sh brach mit
"Environment variable not found: DATABASE_URL" ab.

src/lib/prisma.ts macht jetzt dieselbe URL-Konstruktion einmal
zentral. Damit funktionieren alle Wartungsskripte (reset-admin-
password, cleanup-xss-and-mass-assignment) bei docker exec ohne
Wrapper-Hack. Server-Start ist unbeeinflusst (DATABASE_URL ist da
schon gesetzt).

Live-verifiziert lokal: env -u DATABASE_URL DB_USER=... npx tsx
prisma/reset-admin-password.ts admin@admin.com → success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:15 +02:00
duffyduck 7b9a1981a7 fix: src/ ins Runtime-Image, damit prisma/*.ts-Wartungsskripte laufen
reset-admin-password.ts und cleanup-xss-and-mass-assignment.ts
importieren über '../src/lib/prisma.js' den shared Prisma-Client
+ Helper. Im Production-Container lag src/ aber nicht – nur dist/
für die Server-Runtime. Folge: admin-rescue.sh password ... brach
mit ERR_MODULE_NOT_FOUND ab.

Dockerfile kopiert jetzt zusätzlich src/ + tsconfig.json aus dem
backend-builder Stage ins Runtime-Image. tsx ist eh in dependencies
(nicht devDependencies) – läuft also auch unter --omit=dev. Server
selbst nutzt weiter dist/ via "node dist/index.js"; src/ ist nur
für ad-hoc Wartungsskripte da.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:53:52 +02:00
duffyduck 3dda83314a Admin-Rescue: PW-Reset direkt in DB + Rate-Limit-Reset
Use Case: Admin sperrt sich aus (admin@admin.com ist keine echte
Mailadresse, Passwort-vergessen-Flow kann keine Mail liefern) oder
Brute-Force-Lockout will sich nicht von selbst auflösen.

backend/prisma/reset-admin-password.ts:
- Findet User per Email, hasht neues PW mit bcrypt cost 12
- Schreibt direkt in user.password, setzt tokenInvalidatedAt=now()
  (kickt alle bestehenden Sessions), löscht Reset-Tokens
- Eigenes PW: Komplexitäts-Check 25 Zeichen
- Kein PW-Argument: 28-char Zufallspasswort (alle 4 Klassen
  garantiert), wird einmal in stdout ausgegeben

scripts/admin-rescue.sh:
- password <email> [pw]  → docker exec npx tsx … reset-admin-password
- unlock                  → docker restart opencrm-app (leert
                            In-Memory-Rate-Limit-Store)
- all <email> [pw]        → beides

Live-verifiziert: random-Modus, schwaches PW → klare Fehlerliste,
langes eigenes PW → akzeptiert, unbekannter User → exit 2, bash -n
syntax-check ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:02 +02:00
duffyduck 3e1fc3eab2 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.

passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert

Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
  (getPasswordResetAudience), User → 25, Customer → 12. Kein
  Body-Hint, damit kein Downgrade-Trick möglich.

Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword

Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
  sonst Log-Warnung + Random-Fallback

Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
  zwei API-Calls (PUT + POST /users/:id/password) statt
  Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu

Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
  Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:19:58 +02:00
duffyduck cf8c6c84c2 Security-Hardening Runde 15: Pentest Runde 12 Folge-Fixes
M2-Reste – XSS-Strings + Mass-Assignment-Settings noch in DB:
Idempotentes Cleanup-Script prisma/cleanup-xss-and-mass-assignment.ts.
Strippt HTML aus Customer/User-String-Feldern, entfernt AppSettings
ohne Whitelist-Eintrag. Wird im entrypoint.sh nach Migrations + Seed
einmalig pro Container-Start ausgeführt.

User-Update + password-Feld:
password aus USER_UPDATABLE_FIELDS raus (CREATE behält es), neuer
dedizierter Endpoint POST /api/users/:id/password mit Audit-Log
"Passwort … durch Admin gesetzt" und Komplexitäts-Check.

JS-Runtime-Fehler-Leak:
ORM_LEAK_PATTERNS um TypeError/ReferenceError/SyntaxError/RangeError +
"Cannot read properties of undefined/null" + "is not a function/
defined" erweitert. Greift im globalen res.json()-Wrapper.

POST /contracts substring-Crash:
Controller validiert type/customerId, sonst 400. generateContractNumber
fängt nullish type ab (Fallback "CON").

Seed-Admin-Passwort:
Default "admin" verletzte 12-Zeichen-Policy. Jetzt 16-char
Zufallspasswort (alle 4 Klassen garantiert via Fisher-Yates) oder per
SEED_ADMIN_PASSWORD-ENV überschreibbar. BCRYPT-Cost 12 (war 10).
Passwort wird einmalig in stdout ausgegeben mit Warnung.

AppSettings-Whitelist: companyName + defaultEmailDomain ergänzt
(kamen aus seed.ts, in 1. Whitelist vergessen).

Live-verifiziert:
- POST /contracts {} → 400 "Vertrags-Typ erforderlich" (vorher
  TypeError-Stack)
- PUT /users/6 {password:"HackerPW2026!"} → 200 aber Login mit altem
  PW geht weiter
- POST /users/6/password mit "kurz" → 400 mit Komplexitäts-Fehlern
- Cleanup-Script: planted XSS bereinigt, hackerSetting+debugMode
  entfernt, idempotenter Re-Lauf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:09:13 +02:00
duffyduck d545790a69 Security-Hardening Runde 14: Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip
Pentest Runde 11:

C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body die DB
plätten (3× in einer Pentest-Session passiert). Server erzwingt jetzt
confirm:"FACTORY-RESET-BESTAETIGT" als String. Frontend-API sendet
den Wert automatisch mit.

M1 – Settings Mass Assignment:
PUT /api/settings akzeptierte beliebige Keys (superAdminEmail,
debugMode, allowedOrigins). Neue Whitelist ALLOWED_SETTING_KEYS in
appSetting.service.ts; updateSetting + updateSettings prüfen jeden
Key, unbekannte → 400.

M3 – Prisma-Error-Leak:
Statt 30+ Controller einzeln zu fixen, globaler res.json()-Wrapper
unter /api: error/details-Strings werden durch Pattern-Filter
geschickt, der ORM-/Stack-Trace-Muster zu "Operation fehlgeschlagen"
ersetzt. Original bleibt im Server-Log.

M2 – Stored XSS in Customer/User-Strings:
Neuer stripHtml()-Helper. pickCustomerUpdate/Create + pickUserUpdate/
Create rufen ihn auf jeden String-Wert. Defense-in-Depth gegen PDF/
E-Mail-Template-XSS-Vektoren – React-Frontend ist eh auto-escaped.

Live-verifiziert:
- factory-reset {} / {confirm:true} / {confirm:false} → 400, DB ok
- PUT /settings {superAdminEmail,...} → 400 + Keys aufgezählt;
  PUT /settings {customerSupportTicketsEnabled:"true"} → 200
- PUT /users/99999 → "Operation fehlgeschlagen" (vorher Prisma-Stack)
- PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} →
  gespeichert als "EvilCorp"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:23:12 +02:00
duffyduck ef238b0145 Security-Hardening Runde 13: Live-Vollmacht-Konsistenz + embedded DTOs
Pentest Runde 10:

MEDIUM – Stale Token nach Vollmacht-Widerruf:
Selbst ein frischer Portal-Login lieferte JWT mit representedCustomer-
Ids/representedCustomers, obwohl die Vollmacht widerrufen war. Live-
Check beim Datenzugriff fing das ab (403), aber die UI zeigte weiter
„kann vertreten". customerLogin und getCustomerPortalUser (= /me +
Refresh) filtern representingFor jetzt zusätzlich über
getAuthorizedCustomerIds() – nur Beziehungen mit isGranted=true
landen im Token.

MEDIUM – DTO-Leak in embedded Objekten:
GET /customers/:id lieferte contracts[] mit commission/notes/
portalPasswordEncrypted/nextReviewDate; embedded customer in
/contracts/:id zeigte notes. sanitizeCustomer(Strict) ruft jetzt
sanitizeContract(Strict) auf jedes Element von contracts[] auf;
`notes` ist als PORTAL_HIDDEN_CUSTOMER_FIELDS aufgenommen.

LOW – /tasks?customerId=X gibt 200 mit leerem Array statt 403:
Konsistenz-Fix: wenn Portal-User explizit nach customerId filtert,
die er nicht vertreten darf → 403.

Live-verifiziert:
- Customer 1 vertritt 2+3 (Vollmachten widerrufen) → JWT
  representedCustomerIds=[], /me dito
- Portal /customers/1.contracts[0]: keine Leaks; Admin sieht weiter
  commission/notes; portalPasswordEncrypted generell weg
- Portal /tasks?customerId=2 → 403; /tasks?customerId=1 → 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:47:20 +02:00
duffyduck 7b6b586033 fix: PortalPrivacy weiße Seite – Hooks-Reihenfolge nach early-return
useState + useEffect für den Download-Token standen nach dem
`if (isLoading) return <Laden />` early-return. Beim ersten Render
gab es 2 Hooks, beim zweiten 4 → React-Hook-Order-Mismatch → Crash →
weiße Seite. Mein Fehler aus der Download-Token-Migration (Runde 11).

Hooks vor den early-return verschoben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:33:40 +02:00
duffyduck 3dea381983 fix: UUID-Hashes (Datenschutz-Link) nicht als truncated-ID blocken
Der Integer-Truncation-Filter aus Runde 12 war zu breit: er hat jedes
Pfad-Segment geblockt, das mit einer Ziffer beginnt und nicht aus
reinen Ziffern besteht – also auch UUIDs wie
"3018c9b9-b337-4c9a-a402-b47872f8ddae". Damit warf der Datenschutz-
Link beim ersten Aufruf "Ungültiger Link" (in Wahrheit 400 vom Filter).

Engere Heuristik: ^\d+[a-zA-Z]+$ – reine Ziffern gefolgt von reinen
Buchstaben (`6abc`, `12foo`). UUIDs haben Bindestriche und Hex-
Buchstaben gemischt, werden korrekt durchgelassen.

Live-verifiziert: Datenschutz-Link mit UUID → 200; /customers/6abc →
weiterhin 400; /customers/3 → 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:21:52 +02:00
duffyduck 28c91759df Security-Hardening Runde 12: Information-Disclosure + Input-Validation
Pentest Runde 7 (Anschlussrunde):

MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
  portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
  privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
  portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
  abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
  passende Variante. Mitarbeiter sehen commission/notes weiterhin.

LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.

INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.

Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
  portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
  nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
  portalPasswordEncrypted weg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:51:52 +02:00
duffyduck c744eebfa3 Rate-Limit-Liste: bereits freigegebene IPs ausblenden
Die Liste basiert auf unveränderlichen SecurityEvents – ein Reset
leerte nur den In-Memory-Limiter, aber die historischen Events
blieben weitere 15 Min in der Anzeige stehen ("Freigeben klappt nicht").

Fix: für jede candidate-IP wird der letzte AuditLog-Eintrag
(resourceType=RateLimit) im 15-Min-Fenster geprüft. Liegt er nach dem
letzten Hit der IP, fliegt die IP aus der Liste – aber sobald wieder
ein RATE_LIMIT_HIT nach dem Reset kommt, taucht die IP wieder auf.

Live-verifiziert: trigger → 1 Eintrag; reset → 0 Einträge;
erneuter trigger → 1 Eintrag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:26:12 +02:00
duffyduck 956bc394b8 Rate-Limit-Sperren: Admin-UI zum Freigeben
Bei zu vielen Login-Fehlversuchen war ohne Container-Restart kein Weg
zurück. Jetzt sehen Admins die aktiven Sperren und können einzeln
freigeben.

Backend:
- GET  /api/settings/rate-limits/active (settings:read)
  Liest SecurityEvent RATE_LIMIT_HIT der letzten 15 Min, gruppiert nach
  IP, liefert lastEmail/limiters/hitCount/lastHit.
- POST /api/settings/rate-limits/reset (settings:update)
  Body { ipAddress } → ruft loginRateLimiter.resetKey + passwordReset-
  RateLimiter.resetKey auf (express-rate-limit v7), audited als
  UPDATE auf resourceType=RateLimit.

Frontend:
- Neue Seite /settings/rate-limits: Tabelle mit IP/Email/Limiter/Hits/
  Letzter-Hit/Aktion. Auto-Refresh alle 15s. Freigeben-Button pro IP.
- Kachel in Settings-Übersicht (orange, ShieldOff-Icon, settings:read).

Live-verifiziert: 11 failed Logins → 429 ab dem 11.; Liste zeigt
IP + Email; POST /reset → 200; danach wieder 401 statt 429; Audit-Log
„Rate-Limit für IP 127.0.0.1 manuell freigegeben" angelegt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:20:43 +02:00
duffyduck 69b9a35674 Security-Hardening Runde 11: Pentest Runde 7 (Portal-PW + Download-Tokens)
Hit-List vom Pentester abgearbeitet. Hauptpunkte:

1) Contract/Mail-Credentials (password/internet/sip/simcard, mailbox/send/
   reset-password): ALLE bereits durch canAccess* gesichert, keine Lücke.

2) GET /customers/:id/portal/password (Klartext-Portal-PW-Abruf):
   fehlender canAccessCustomer-Check ergänzt. Defense in depth gegen
   versehentliche customers:update-Permission an Portal/eingeschränkte
   Mitarbeiter.

3) Admin-Endpoints (factory-reset, developer/*, audit-logs/rehash,
   audit-logs/customer): durch admin-Permissions geschützt – Portal-User
   haben diese nicht.

4) Token-in-URL (NIEDRIG): Langlebige Access-JWTs landeten als ?token= in
   URLs für iframe-PDFs, Audit-Export-Window etc. → nginx-Logs +
   Browser-History + Referer.
   Lösung: kurzlebige Download-Tokens.
   - signDownloadToken() liefert JWT mit type='download', exp=60s
   - Auth-Middleware akzeptiert type='download' AUSSCHLIESSLICH via
     ?token=, niemals als Bearer-Header
   - POST /api/auth/download-token Endpoint (authenticated)
   - Frontend: authApi.getDownloadToken() utility
   - 4 Stellen migriert: AuditLog-Export, PdfTemplate-Preview-iframe,
     PdfTemplate-Generate, ContractDetail-PDF-Generate (2x),
     Portal-Privacy-PDF
   - fileUrl/getAttachmentUrl sind synchron + breit gestreut – Migration
     bleibt für Folge-PR

Live-verifiziert:
- Download-Token: 1773 Zeichen, type=download, exp-iat=60s
- als Header → 401 (Falscher Token-Typ), als ?token= → 200
- portal-user (Customer 3) auf customers/2/portal/password → 403

Rate-Limiter-Check: express-rate-limit Fixed-Window, kein Reset bei jedem
Request (Pentester-Klage „Fenster reseted sich" stimmt mit dem Code nicht
überein – wahrscheinlich Retry-After-Misinterpretation). Kein Code-Bug
identifiziert; ggf. später Admin-Override-Endpoint nachrüsten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:40:00 +02:00
duffyduck a982795388 Security-Hardening Runde 10: Pentest Runde 6 (8 Findings + struktureller Audit-Sweep)
KRITISCH:
- emails/:id/thread bekommt canAccessCachedEmail
- customers/:customerId/representatives/search bekommt canAccessCustomer
  (Buchstaben-Brute-Force konnte sonst die Kunden-DB enumerieren)

HOCH:
- birthdays/upcoming: Portal-User → 403 (Name/E-Mail/Telefon/Geb-Datum
  aller Kunden leakte)
- contracts/:id/history (GET/POST/PUT/DELETE) bekommt canAccessContract
- mailbox-accounts / unread-count / contracts/:id/emails/folder-counts
  bekommen canAccessCustomer bzw. canAccessContract
- Vertreter-Vollmacht-Check ist jetzt live: neuer Helper
  getPortalAllowedCustomerIds() in accessControl.ts ruft
  hasAuthorization() für jedes vertretene Customer ab. Eingesetzt in
  getTasks/createSupportTicket/createCustomerReply/getAllTasks/
  getTaskStats und updateCustomerConsent. Widerrufene Vollmachten
  haben jetzt SOFORT keinen Zugriff mehr (vorher: bis JWT abläuft).

MITTEL:
- confirmPasswordReset speichert portalPasswordEncrypted nicht mehr
  beim Self-Service-Reset (war nur für Admin-OTPs gedacht); +
  portalPasswordMustChange=false explizit
- getCustomers pagination total reflektiert jetzt nur erlaubte IDs
  (über DB-Filter in customerService.getAllCustomers)

Audit-Sweep (defense in depth, falls Rolle versehentlich Update-
Permissions bekommt):
- 16 cachedEmail-Operationen (markAsRead, toggleStar, assign/unassign,
  save-as-pdf/invoice/contract-document, save-to, attachment-targets,
  trash-ops)
- 4 contract-Operationen (createFollowUp, createRenewal, snoozeContract,
  removeContractMeter)
- 12 sub-CRUD-Operationen (address/bankcard/document/meter
  update+delete, meter-reading add/update/delete/transfer)
- 2 representative-Operationen (add/remove)

Live-verifiziert: Portal-Customer-3 auf alle fremden IDs → 403,
Admin sieht alles, eigene Ressourcen weiterhin 200, Customer 1 mit
widerrufener Vollmacht für Customer 3 → 0 fremde Verträge in der
Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:47:17 +02:00
duffyduck 38c2d82c02 Security-Hardening Runde 9: Pentest Runde 5
KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check:
Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des
alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für
den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer
laden, portalPasswordMustChange=true erzwingen, sonst 403.

NIEDRIG – consentHash leakte über GET /customers/🆔
Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt
in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer-
Response gestrippt. Wer ihn legitim braucht, holt ihn über
/gdpr/customer/:id/consent-status.

NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records:
POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress
und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert
– Frontend liest eh nur success.

Live-verifiziert:
- Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false →
  erneuter Aufruf 403
- GET /customers/3 → consentHash null, portalPasswordHash null
- /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar
- Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:57:09 +02:00
duffyduck 75c833500e Security-Hardening Runde 8: Cockpit-IDOR (Portal sah ALLE Kunden)
Pentest Runde 4 – HOCH:
GET /api/contracts/cockpit gab Portal-Usern mit contracts:read
die kompletten Vertrags-, Ausweis- und Zählerstand-Daten ALLER
Kunden zurück. Realer Angriff erfolgreich durchgespielt.

Fix:
contractCockpitService.getCockpitData({ customerIds? }) – wenn
gesetzt, werden ALLE internen Queries (Contract, CustomerConsent
GRANTED/WITHDRAWN, IdentityDocument-Expiry, MeterReading-Reported)
auf diese Customer-IDs eingeschränkt.

Controller getCockpit ermittelt customerIds analog getContracts:
- isCustomerPortal → [eigene, ...vertretene mit Vollmacht]
- sonst (Mitarbeiter/Admin) → undefined (alle Kunden)

Live-verifiziert:
- Admin: 17 Verträge über 3 Kunden (Baseline)
- Portal-User Customer 1: 12 Verträge, alle mit customerId=1
- Portal-User Customer 3: 3 Verträge, alle mit customerId=3
- 0 fremde Verträge in Portal-Responses

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:55:38 +02:00
duffyduck a7d12b8540 Security-Hardening Runde 7: Pentest Runde 3 (3 Findings)
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>
2026-05-16 19:39:02 +02:00
duffyduck 8534be22d0 Einmalpasswort-Flow für Portal-Credentials
Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein
echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden
Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise
auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem
Passwort kommt er ins Portal.

Schema:
- Customer.portalPasswordMustChange: Boolean @default(false)

Backend:
- sendPortalCredentials setzt Flag = true + erweitertes Mail-Template
  mit Einmalpasswort-Warnung
- customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null,
  portalLastLogin aktualisiert), Response enthält mustChangePassword=true
  in token-payload + user-objekt
- setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf
- changeInitialPortalPassword: neue Service-Funktion + Endpoint
  POST /api/auth/change-initial-portal-password (authenticated, nur
  Portal-User), validiert Komplexität, setzt neuen Hash, löscht
  Encrypted, invalidiert Session via portalTokenInvalidatedAt

Frontend:
- User-Type erweitert um mustChangePassword
- AuthContext.customerLogin gibt User zurück (für sofortige Routing-
  Entscheidung)
- Login.tsx: redirect zu /change-initial-password wenn mustChangePassword
- ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite
- ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff
- ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint,
  Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg

Live-verifiziert (10 Schritte):
- Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und
  consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400,
  komplex=200 → neues Passwort funktioniert → Session invalidated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:48:13 +02:00
duffyduck f0c97cd46d todo.md: Passwort-Komplexität + Real-IP-Fix dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:52 +02:00
duffyduck 8a5ffbb563 Passwort-Komplexität + Portal-Credentials-UX
validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.

Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
  (nur wenn portalEnabled aktiv)

Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.

Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:26:11 +02:00
duffyduck 6af1a4bbd4 fix(security): trust proxy = 1 bei HTTPS_ENABLED – echte Client-IP statt Proxy-IP
Wenn der TLS-Reverse-Proxy (Nginx Proxy Manager) auf einer SEPARATEN Box
läuft, kommt nicht von 127.0.0.1 → `trust proxy = 'loopback'` greift
nicht → req.ip bleibt die NPM-IP statt der echten Client-IP. Folgen:

- Rate-Limiter sieht alle Angriffe als von "einem" Client (= NPM)
- Security-Monitor loggt Proxy-IP statt Angreifer-IP (Beweis im
  Audit-Log: "ACCESS_DENIED ... 172.0.2.12" für alle Versuche)
- IDOR-Threshold-Detection (>5 in 5 min pro IP) triggert auf der NPM-IP
  und blockt damit alle legitimen User durch denselben Proxy

Fix: bei HTTPS_ENABLED=true `trust proxy = 1` (vertraue genau einem Hop –
den vorgelagerten TLS-Proxy). Bei HTTPS_ENABLED=false bleibt es bei
`loopback` (keine Proxy-Annahme bei direkter http://ip:port-Nutzung).

Voraussetzung für HTTPS_ENABLED=true: Backend ist nicht direkt aus
dem Internet erreichbar, sonst könnte ein direkter Connect ein
X-Forwarded-For faken und den Limiter umgehen. Bei NPM-Setup
gewährleistet durch Docker-Network + nicht-veröffentlichten
Backend-Port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:24:21 +02:00
duffyduck 92d2e62e79 security: portalPasswordHash + Encrypted aus embedded customer in /contracts/* entfernen
Folgefix zum CRITICAL-IDOR auf Stressfrei-Sub-Routes: der separate
/customers/:id-Endpoint sanitizt seinen Output schon, aber GET /contracts/:id
embeddete weiterhin das volle Customer-Objekt inkl.
- portalPasswordHash (bcrypt-Hash des Portal-Login-Passworts)
- portalPasswordEncrypted (AES-256-GCM des Klartext-Passworts)
- portalPasswordResetToken (langlebiger 1-time-Token)

Zwei Lecks im contract.service:
- getContractById hatte `customer: true` ohne Sanitize
- createContract hatte dasselbe Muster

Beide jetzt mit sanitizeCustomerStrict() nach dem Load. Der Helper war schon
im utils/sanitize.ts vorhanden – wurde nur nicht aufgerufen.

Live-verifiziert: GET /api/contracts/1 → embedded customer enthält 30 saubere
Felder, KEIN portalPasswordHash/Encrypted/ResetToken mehr.

Weitere `customer: true`-Stellen geprüft und freigegeben:
- pdfTemplate.service.generateFilledPdf: nur internal, gibt PDF-Buffer zurück
- cachedEmail.controller.saveEmailAsPdf: nur internal für File-Ops
- getAllContracts: schon mit explizitem Select (5 sichere Felder)
- updateContract: kein customer-Include

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:01 +02:00
duffyduck 08310ac302 security: CRITICAL IDOR-Fix auf Stressfrei-Email-Sub-Routes
Pentest hat einen echten Credential-Exfiltration-Angriff erfolgreich
durchgespielt: als Portal-User von Kunde A komplette Klartext-IMAP/SMTP-
Zugangsdaten der Mailbox von Kunde B abgreifbar.

Root Cause: GET /api/stressfrei-emails/:id hatte canAccessStressfreiEmail-
Check, ALLE 8 Sub-Endpoints unter :id/* hatten nur `authenticate +
requirePermission('customers:read')` — was jeder Portal-User de facto hat.

Betroffene Controller (alle gefixt mit canAccessStressfreiEmail als erster
Zeile):

stressfreiEmail.controller.ts:
- updateEmail (PUT /:id)
- deleteEmail (DELETE /:id)
- resetPassword (POST /:id/reset-password)

cachedEmail.controller.ts:
- getMailboxCredentials (GET /:id/credentials) ← KRITISCHSTER, lieferte
  Klartext-IMAP/SMTP-Passwort + Server-Daten der fremden Mailbox
- getFolderCounts (GET /:id/folder-counts)
- syncAccount (POST /:id/sync)
- sendEmailFromAccount (POST /:id/send) — fremde Mailbox zum Versand
  missbrauchbar
- enableMailbox (POST /:id/enable-mailbox)
- syncMailboxStatus (POST /:id/sync-mailbox-status)

Security-Monitor: canAccessResourceByCustomerId emittiert bei jedem
Fehlversuch ein ACCESS_DENIED MEDIUM-Event. Threshold-Detection erzeugt
bei >5 Versuchen in 5 min ein CRITICAL SUSPICIOUS-Event + Sofort-Alert.

Live-verifiziert (Portal-User Kunde A versucht Email-ID von Kunde B):
- alle 8 Sub-Routes → HTTP 403
- eigene Email-ID → 200/400 (Ownership-Check OK)
- 8× ACCESS_DENIED MEDIUM im Security-Monitor

Doku in docs/SECURITY-HARDENING.md als Runde 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:47:54 +02:00
duffyduck 72f7a9dbdb docs: BREACH-/-Marker konkret entfernen – exact-match-Location erklärt
Folge zur User-Frage: Snippet auch für / anwenden. Wichtiger Punkt
dokumentiert: NPM-Custom-Location mit prefix-`/` würde ALLE Pfade
außer /api/* fangen (auch /assets/*.js) → JS-Bundle unkomprimiert
~500 KB statt 150 KB. Stattdessen exact-match `location = /` nutzen,
das fängt nur die Root-URL ohne weitere Pfad-Komponente.

Zwei Varianten dokumentiert:
- Variante A: Custom Location im NPM-UI mit „= /" (falls Feld das
  akzeptiert)
- Variante B: server-level snippet im Advanced-Tab des Proxy-Hosts

Plus Verifikations-Befehle für „/" ohne gzip + „/assets/*.js" weiter
mit gzip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:35:48 +02:00
duffyduck c5dc271759 docs: BREACH-Marker auf SPA-Root / als bewusst akzeptiert dokumentieren
Pentest-Tools (testssl) melden BREACH weiter für die Root-URL, weil
die SPA-index.html bewusst weiter gzip-komprimiert ausgeliefert wird
(Performance: 50 KB → ~10 KB). Das ist nicht ausnutzbar, weil keine
Secrets/Reflektionen im HTML-Body sind. README erklärt jetzt explizit
warum + wie man es trotzdem loswerden kann (zusätzliche NPM-Custom-
Location für /, Trade-off: 40 KB extra pro Tab-Reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:59:49 +02:00
duffyduck 1451e362ff chore(env): JWT_EXPIRES_IN 15m + JWT_REFRESH_EXPIRES_IN dokumentieren
Folge-Aufräumen zur Refresh-Cookie-Migration:
- .env.example: JWT_EXPIRES_IN von 7d auf 15m (Access-Token-Lifetime),
  neue JWT_REFRESH_EXPIRES_IN=7d. Kommentar erklärt das Access-/Refresh-
  Pattern (Memory vs. httpOnly-Cookie, transparenter Refresh).
- docker-compose.yml: durchreichen + Default mit 15m statt 7d, plus
  JWT_REFRESH_EXPIRES_IN als neue Variable.

Bestandsinstallationen mit altem JWT_EXPIRES_IN=7d in der .env
funktionieren weiter (die Variable überschreibt den Default), aber bei
neuen Setups ist sofort der Branchenstandard aktiv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:53:40 +02:00
duffyduck 8188d17c87 fix(gdpr): processedBy aus useAuth statt totem localStorage('user')
localStorage('user') wird seit dem AuthContext-Umbau (Refresh-Cookie-
Pattern) nirgendwo mehr gesetzt → liefert immer null → der Fallback
ließ den `processedBy` in der GDPR-Verarbeitungs-Spur immer auf
'System' fallen, auch wenn ein echter User die Aktion ausgelöst hat.

Subtiler Audit-Trail-Bug, kein Sicherheitsproblem (User-Identitätsdaten
sind kein Geheimnis und waren im React-State eh sichtbar). Aber
funktional jetzt korrekt: useAuth().user.email landet als
`processedBy` im Backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:51:02 +02:00
duffyduck c4e62f0f50 docs: Pentest-Runden 11 + 12 in SECURITY-HARDENING + README aktualisieren
SECURITY-HARDENING.md:
- Runde 11 "Externer Pentest-Folge: Header-Hygiene + Klartext-Audit":
  HSTS-Doppel-Header weg, Cache-Control je nach Pfad differenziert,
  CSP No-Fallback-Direktiven + frame-ancestors auf 'self', BREACH-
  Mitigation via gzip off im Reverse-Proxy für /api/*, Server-/
  X-Served-By-Banner entfernt, Audit-Log für die 6 Klartext-Passwort-
  Read-Endpoints (CRITICAL).
- Runde 12 "JWT raus aus localStorage": Branchenstandard-Refresh-Cookie-
  Pattern für die SPA. Access-Token (15 min) nur in JS-Memory,
  Refresh-Token (7d) im httpOnly-Cookie. Auth-Middleware verweigert
  Refresh-Tokens als Bearer (type-Claim). Axios-Interceptor mit
  Single-Flight-Refresh-Retry. Tabelle der Live-Tests.

README.md:
- Tech-Stack-Auth-Zeile beschreibt jetzt die Access/Refresh-Architektur
- .env-Beispiel: JWT_EXPIRES_IN=15m + neue JWT_REFRESH_EXPIRES_IN=7d
- Production-Deployment-Hinweis: Frontend und API müssen über dieselbe
  Origin laufen (SameSite=Strict-Cookie), sonst funktioniert /auth/refresh
  cross-site nicht und User wird alle 15 min ausgeloggt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:12:05 +02:00
duffyduck 9830ac29a5 security: JWT raus aus localStorage – Refresh-Cookie-Pattern für SPA
Behebt das Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS war
der Token JS-erreichbar → Angreifer könnte alle Anbieter-Credentials
abrufen. Branchenstandard-Lösung für SPAs jetzt umgesetzt.

Architektur:
- Access-Token: 15 min Lifetime, lebt NUR im JavaScript-Memory
  (api.ts tokenStore + AuthContext). Kein localStorage mehr.
- Refresh-Token: 7 Tage, im httpOnly-Cookie (Secure bei HTTPS_ENABLED,
  SameSite=Strict, Path=/api/auth). JavaScript hat keinen Zugriff →
  XSS klaut max. einen 15-min-Access-Token.

Backend:
- signAccessToken/signRefreshToken mit `type`-Claim
- Auth-Middleware verweigert Tokens mit type=refresh
- POST /api/auth/login + /customer-login: setzt refresh_token-Cookie,
  gibt access-Token im Body
- POST /api/auth/refresh: liest Cookie, rotiert ihn, gibt neuen Access
  aus. Prüft tokenInvalidatedAt (Logout/Rollenänderung = sofortige
  Invalidation auch des Refresh-Tokens)
- POST /api/auth/logout: löscht Cookie + setzt tokenInvalidatedAt
- cookie-parser als neue Dependency

Frontend:
- api.ts: in-memory tokenStore (kein localStorage); withCredentials=true
  für Cookie-Roundtrip; axios-Response-Interceptor mit
  Single-Flight-Refresh-Retry bei 401 (Original-Request wird
  transparent retried mit neuem Token)
- AuthContext: beim App-Start /auth/refresh aufrufen → wenn Cookie
  noch gültig, ist der User automatisch eingeloggt. Tab-Reload
  funktioniert weiterhin obwohl Access-Token nur in memory ist.
- 9 alte `localStorage.getItem('token')`-Stellen migriert auf
  `getAccessToken()` (PDF-Preview-iframe, Audit-Log-CSV-Export,
  DB-Backup-Download, File-Download-URLs, Portal-PDF-Link)

Live verifiziert:
- Login setzt Cookie (httpOnly, SameSite=Strict, Path=/api/auth) + Bearer
- API mit Bearer: 200; ohne: 401
- Refresh mit Cookie: rotiert sauber + neuer Access-Token im Body
- Refresh-Token als Bearer abgewiesen: 401 ("Falscher Token-Typ")
- Logout: Cookie gelöscht, danach /refresh → 401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:06:17 +02:00
duffyduck 0943f11999 security: Audit-Log für alle Klartext-Passwort-Reads (CRITICAL)
Pentest-Finding "Klartext-Passwörter über API abrufbar (HIGH, post-auth)"
adressiert: reversible Verschlüsselung der Anbieter-/Portal-Logins ist
by-design (Feature "Login anzeigen" braucht sie zwingend), aber jeder
einzelne Decrypt-Vorgang muss im Audit-Log nachvollziehbar sein. Bisher
schrieb KEINER der 6 betroffenen Endpoints einen Eintrag.

Behoben in:
- getPortalPassword (Customer-Portal-Login)
- getContractPassword (Anbieter-Login z.B. Vattenfall, EWE, …)
- getSimCardCredentials (PIN/PUK)
- getInternetCredentials (DSL-Login)
- getSipCredentials (Telefon-/VoIP-Login)
- getMailboxCredentials (Stressfrei-IMAP/SMTP)

Alle nutzen `action: 'READ'` mit eigenem ResourceType + Sensitivity
CRITICAL via determineSensitivity-Map. Label nennt explizit
"Klartext … entschlüsselt" + Resource-ID, damit im AuditLog-Viewer
auf einen Blick erkennbar ist, wer wann welches Passwort eingesehen
hat (DSGVO + Insider-Threat-Erkennung).

Live verifiziert: nach Klick auf getPortalPassword erscheint im
AuditLog der Eintrag "READ PortalPassword CRITICAL – Klartext-Portal-
Passwort von Kunde #1 entschlüsselt".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:33:26 +02:00
duffyduck e2cd26a29e feat(monitoring): Manueller Refresh-Button im Sicherheits-Monitoring-Log
Auto-Refresh läuft schon alle 30 s im Hintergrund (refetchInterval),
aber wer nach einem getesteten Login-Versuch sofort sehen will, ob
das Event im Log landet, will nicht 30 s warten. Refresh-Button neben
"Log leeren" + "Pro Seite"-Selector invalidiert den
monitoring-events-Query → sofortiger Refetch. Spin-Animation während
des Loads, deaktiviert wenn schon ein Load läuft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:20:00 +02:00
duffyduck f6df97226d feat(email): Weiterleiten + Erneut senden im Detail-Pane
Zwei Aktionen, die der existierende Reply-Pfad bisher nicht abdeckte:

1. Weiterleiten (Compose-Modal-Forward-Modus):
   - Neuer Button im EmailDetail, neben "Antworten"
   - ComposeEmailModal akzeptiert jetzt einen `forwardOf` prop und
     füllt das Formular im Forward-Stil vor:
     * To leer (User trägt selbst ein)
     * Subject mit "Fwd:"-Prefix
     * Body mit zitierten Headern (Von, An, Datum, Betreff) +
       Original-Text
   - Titel des Modals reagiert ("Antworten" / "Weiterleiten" /
     "Neue E-Mail")

2. Erneut senden (One-Click-Resend):
   - Neuer Button im EmailDetail; schickt die Mail nochmal an die
     ursprüngliche toAddresses (= die Stressfrei-Adresse selbst).
     Plesk routet dann gemäß der HEUTE hinterlegten Forwards –
     Use-Case: die Stressfrei-Forward-Adresse wurde nach Empfang
     umgestellt, der Empfang soll beim neuen Forward-Empfänger
     landen.
   - Confirm-Dialog erklärt den Vorgang und warnt explizit, dass
     Anhänge nicht erneut mit gesendet werden (Anhänge wären
     IMAP-Refetch, dafür "Weiterleiten" nutzen).
   - Toast-Feedback für Erfolg/Fehler.
   - Im TRASH-Folder wird der Resend-Button bewusst nicht
     eingeblendet (kein sinnvoller Use-Case dort).

Backend braucht keine neuen Endpoints – beide Aktionen nutzen die
bestehenden `stressfreiEmailApi.sendEmail` + `cachedEmailApi.getById`
(letztere für den Body, der ohnehin schon im Detail-View geladen ist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:44 +02:00
duffyduck 185b38dc55 feat(email): Suchleiste + erweiterte Filter im Email-Postfach
Variante B aus der Trade-off-Diskussion: Suchleiste über der Email-Liste
plus eine ausklappbare Box mit Detail-Filtern, alle AND-verknüpft.

Backend:
- EmailListOptions um search + 9 Detail-Filter erweitert (fromFilter,
  toFilter, subjectFilter, bodyFilter, attachmentNameFilter,
  hasAttachments, isRead, isStarred, receivedFrom, receivedTo)
- getCachedEmails baut die where-Klausel:
  * `search` → OR über Subject/From-Address/From-Name/Body (Volltext-
    Quicksearch)
  * Feldspezifische Filter werden AND-verknüpft an die where gehängt;
    From-/Body-Filter intern als kleine OR-Subqueries (Match in
    Adresse ODER Name; Match in textBody ODER htmlBody)
- Controller-Parser akzeptiert die Filter als Query-Parameter
  (parseBoolParam/parseDateParam tolerieren leere/invalide Werte)

Frontend:
- Suchleiste mit X-Button zum Leeren + Filter-Toggle mit Badge (zeigt
  Anzahl aktiver Filter)
- Ausklappbare Filter-Box: Von, An, Betreff, Inhalt, Datum von/bis,
  Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status
- Filter-State fließt via useMemo + queryKey in den useQuery → React
  Query macht automatisch ein Re-Fetch bei jeder Änderung
- "Alle zurücksetzen"-Button räumt komplett auf
- Nicht für TRASH-Folder eingeblendet (eigener Pfad ohne Filter-API)

Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung. Reale Such-Use-Cases im Email-Kontext sind
quasi immer AND-verknüpft; Bool-Builder bringt mehr Bedienprobleme
als Mehrwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:43 +02:00
duffyduck 51eb12b414 fix(stressfrei): Refresh-Button nur bei provisioned + Auto-Heilung im Status-Sync
User-Feedback: der Refresh-Button war auch bei nicht-provisionierten
Adressen sichtbar (die nur als DB-Eintrag ohne Plesk-Pendant existieren).
Klick darauf gab korrekt einen Fehler, war aber unschön.

Bedingung wieder auf `emailItem.isProvisioned` einschränken. Für
historische Einträge, bei denen das Flag wegen des alten Bugs nie
gesetzt wurde, gibt es jetzt einen automatischen Reconcile-Pfad:

`syncMailboxStatus` (wird beim Öffnen jedes Edit-Modals aufgerufen)
prüft nicht mehr nur `hasMailbox`, sondern auch `isProvisioned`:
- Provider antwortet "existiert" + DB sagt isProvisioned=false
  → DB-Flag auf true ziehen + provisionedAt setzen
- Provider antwortet "nicht da" + DB sagt isProvisioned=true
  → DB-Flag auf false (Adresse wurde im Plesk-UI manuell gelöscht)
- hasMailbox wird zusätzlich konsistent gehalten

Damit heilen sich falsch markierte Adressen automatisch, sobald der
User sie einmal aufmacht zum Bearbeiten – der Refresh-Button erscheint
dann beim Re-Open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:11:47 +02:00
duffyduck c2ebc7cf1e fix(stressfrei): sync-forwarding sichtbar + Passwort-Push + Toast-Meldungen
Drei Verbesserungen am gestrigen Sync-Feature:

1. Bug-Fix: isProvisioned wurde nie auf true gesetzt
   `createEmail` mit `provisionAtProvider: true` hat das Flag
   `isProvisioned` nie gesetzt → blieb auf @default(false). Damit
   blieb der Refresh-Button in der UI unsichtbar (Bedingung
   `emailItem.isProvisioned`). Jetzt:
   - createEmail setzt isProvisioned + provisionedAt korrekt
   - Self-Healing: syncForwardingForEmail setzt das Flag nachträglich
     auf true sobald der Provider-Aufruf erfolgreich war (Backfill
     für historisch falsch markierte Einträge)
   - UI-Sichtbarkeit: Bedingung entfernt – der Button erscheint jetzt
     immer; ein Klick auf eine nicht-provisionierte Adresse liefert
     eine sprechende Fehlermeldung statt stiller Verstecken

2. Passwort-Push bei hasMailbox: true
   Bisher wurden nur die Forwards aktualisiert. Jetzt entschlüsselt
   syncForwardingForEmail bei Mailbox-Adressen zusätzlich das im CRM
   gespeicherte Passwort und setzt es am Provider neu – Self-Healing
   für IMAP/SMTP-Logins falls jemand im Plesk-UI manuell ein anderes
   Passwort gesetzt hat. Response enthält `passwordReset: true` als
   Marker.

3. react-hot-toast statt alert()
   Erfolgs-Toast listet die neu gesetzten Forward-Targets + Hinweis
   ob Passwort-Reset durchgeführt wurde. Fehler-Toast zeigt die
   Backend-Fehlermeldung (z.B. „E-Mail-Adresse beim Provider nicht
   gefunden – wurde sie dort gelöscht?").

Audit-Log-Label enthält jetzt sowohl Forwards als auch Passwort-Reset-
Marker, damit der Vorgang im AuditLog nachvollziehbar bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:06:26 +02:00
duffyduck b4be3cebfb feat(stressfrei): Weiterleitungen manuell synchronisieren
Nach Änderung der Kunden-Stamm-E-Mail (oder der defaultForwardEmail in
den Provider-Settings) müssen die Plesk-Forwards der Stressfrei-Adressen
des Kunden auf den neuen Wert umgestellt werden. Bisher ging das nur
manuell pro Adresse im Plesk-UI – jetzt mit einem Klick pro Adresse im
CRM.

Backend:
- emailProviderService.setEmailForwardTargets(localPart, targets[]):
  dünner Wrapper um die schon vorhandene IEmailProvider-Methode
  updateForwardTargets (`set:email1,email2` ersetzt komplett, idempotent)
- stressfreiEmail.service.syncForwardingForEmail(id): lädt Kunde +
  Provider-Config, baut [customer.email, defaultForwardEmail] und ruft
  den Provider auf
- POST /api/stressfrei-emails/:id/sync-forwarding, customers:update,
  Audit-Log mit den neuen Forward-Targets im Label

Frontend:
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse,
  sichtbar nur wenn isProvisioned (sonst sinnlos). Confirm-Dialog
  zeigt die Ziele, Tooltip erklärt den Vorgang.
- ExternalLink-Icon neben der E-Mail in der Kundenakte (Stammdaten →
  Kontakt) öffnet den Stressfrei-Tab des Kunden in neuem Tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:48 +02:00
duffyduck 083913cadb docs: README – more_clear_headers Server X-Served-By dazu (Banner weg)
Im selben /api/-Custom-Location-Block des BREACH-Fixes auch gleich die
Server-Banner-Hygiene ergänzt: `more_clear_headers Server X-Served-By;`
über das headers-more-Modul (bei NPM standardmäßig dabei) entfernt die
Information-Disclosure-Header, die Pentest-Tools wie Nikto sonst als
low-Finding flaggen.

Plus zusätzlicher Verifikations-curl, der prüft dass beide Header weg
sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:36:49 +02:00
duffyduck 4c0cc90734 docs: README – BREACH-Schutz via gzip off für /api/* am Reverse-Proxy
Pentest mit testssl markiert die Prod-Instanz wegen aktivierter gzip-
Komprimierung als BREACH-anfällig (CVE-2013-3587, "Ausnutzbar: Ja").
Die JWT-SPA-Architektur hält das Risiko praktisch klein, der Audit-
Marker bleibt aber medium.

README-Sektion „Production-Deployment" um expliziten Hinweis ergänzt:
gzip nur für statische Assets erlauben, für /api/* deaktivieren. Mit
Setup-Schritten für Nginx Proxy Manager (Custom Locations) und Plain
Nginx + Verifikationsbefehl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:33:54 +02:00
duffyduck 70e97d3ece security: Header-Hygiene-Runde 11 (Pentest-Cleanup)
Behebt die drei behebbaren Klassen aus dem ZAP-/Nikto-Audit vom 2026-05-16:

1. HSTS-Doppel-Header (18 Findings):
   Helmet's strictTransportSecurity komplett deaktiviert. Der Nginx Proxy
   Manager vor der CRM-VM setzt HSTS bereits (Force SSL + HSTS Enabled +
   HSTS Sub-domains via UI). Doppelter Header verletzt RFC 6797.

2. Cache-Control (~10 Findings):
   - /api/* → 'no-store' (sensible JSON-Daten)
   - SPA-HTML (/, /robots.txt, /sitemap.xml, /vite.svg) → 'no-store,
     must-revalidate' (sonst hängt Browser nach Deploy an alter index.html
     mit alten Asset-Hashes fest)
   - /assets/*.{js,css} (Vite-Build mit Content-Hash) → 'public,
     max-age=31536000, immutable'

3. CSP No-Fallback-Direktiven (2 Findings):
   worker-src, manifest-src, media-src jetzt explizit auf 'self'. ZAP
   meckert sonst "Failure to Define Directive with No Fallback".

Bewusst NICHT gefixt: style-src 'unsafe-inline' (11 Findings). Tailwind +
React (style={{…}}) erzeugen viele inline-styles; nonce-/hash-basierte CSP
wäre ein größerer Build- und Code-Refactor mit eher kosmetischem Gewinn,
da der primäre XSS-Schutz weiterhin via script-src 'self' und Input-
Sanitization greift.

Live verifiziert (Headers via curl gegen HTTPS_ENABLED=true Container):
- / → 'no-store, must-revalidate', kein HSTS
- /assets/index-*.js → 'public, max-age=31536000, immutable', kein HSTS
- /api/health → 'no-store', kein HSTS
- SPA-Fallback (/sitemap.xml, /robots.txt) → 'no-store, must-revalidate'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:46:49 +02:00
duffyduck 8dff0310a6 fix(csp): frame-ancestors auf 'self' – PDF-Vorschau-iframe ging nicht
Das CSP `frame-ancestors 'none'` blockte ALLE iframe-Embeddings, auch
same-origin – damit ließ sich die annotierte PDF-Vorschau im Editor für
PDF-Auftragsvorlagen nicht laden. Browser zeigten je nach Variante
"Verbindung abgelehnt" oder einen CSP-Violation-Fehler.

CSP überschreibt X-Frame-Options, der alte SAMEORIGIN-Header reichte also
nicht aus. Auf 'self' wechseln: eigene App darf eigene Resourcen embeden,
externe Sites weiterhin gesperrt (was X-Frame-Options bereits regelt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:42 +02:00
duffyduck ab971618d5 factory-import: --save-as-builtin Flag + README-Überarbeitung
Schließt die Lücke „nach Import landet die ZIP nicht im Image-Default":

  ./factory-import.sh --save-as-builtin
  → entpackt die ZIP nach erfolgreichem DB-Import zusätzlich in
    backend/factory-defaults/ (alter Inhalt vorher aufgeräumt, README.md
    und .gitkeep bleiben). Beim nächsten Image-Build sind die Defaults
    drin und seeden frische VMs automatisch.

README-Abschnitt „Factory-Defaults" komplett überarbeitet:
- Drei Transport-Pfade explizit erklärt (laufende DB / Drop-Box / Image)
- HTML-Standardtexte + AppSetting-Whitelist dokumentiert
- Auto-Seed-Verhalten + Berechtigungen aktualisiert
- Typische Workflows als End-zu-End-Sequenz inkl. scp-Sync

Live verifiziert: STALE_FILE.txt im backend/factory-defaults/ wurde beim
--save-as-builtin sauber entfernt, README.md blieb erhalten, Subfolder neu
befüllt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:04:02 +02:00
duffyduck 4407bbfbb8 factory-defaults: CLI-Sync zwischen dev und prod
Zwei kleine Bash-Wrapper im Repo-Root, die den vorhandenen Export- und
Import-Endpoint per curl ansteuern und damit den Hin- und Her-Transfer von
Stammdaten + HTML-Templates zwischen Instanzen ohne Browser ermöglichen.

  ./factory-export.sh                    # ZIP nach factory-exports/
  ./factory-import.sh                    # nimmt jüngste ZIP automatisch
  ./factory-import.sh path/zur.zip       # explizit

Konfigurierbar via OPENCRM_URL / OPENCRM_EMAIL / OPENCRM_PASSWORD;
ohne PASSWORD wird interaktiv abgefragt.

Workflow: prod erweitert Anbieter → ./factory-export.sh → scp → dev
./factory-import.sh – funktioniert in beide Richtungen.

`factory-exports/` ist gitignored (nur .gitkeep getrackt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:51:19 +02:00
duffyduck 365c7994d5 factory-defaults: builtin-Werkseinstellungen beim Auto-Seed einspielen
Neue VMs sollen direkt mit den im Repo abgelegten Stammdaten +
Auftragsvorlagen + HTML-Templates hochkommen, ohne dass man jedes Mal
manuell ein ZIP hochlädt.

- Dockerfile: kopiert backend/factory-defaults nach
  /app/factory-defaults-builtin und backend/scripts nach /app/scripts
- seed-factory-defaults.ts: ROOT-Pfad über FACTORY_DEFAULTS_DIR überschreibbar
- entrypoint.sh: nach erfolgreichem Auto-Seed läuft `tsx
  scripts/seed-factory-defaults.ts` mit FACTORY_DEFAULTS_DIR auf den
  builtin-Pfad. Trigger NUR bei frischer DB (RAN_SEED=true), bestehende
  Installs werden nie nachträglich überschrieben.

`backend/factory-defaults/*` bleibt gitignored – Inhalte legt jeder
Operator-User selbst lokal ab (z.B. via Export-ZIP entpacken), sie landen
beim nächsten Container-Build im Image.

Live verifiziert: frischer Container mit RUN_SEED=true zieht 10 Anbieter,
4 Tarife, 18 Kündigungsfristen, 18 Laufzeiten, 8 Kategorien, 2 PDF-Vorlagen
und 2 HTML-Templates ein; PDFs landen mit eindeutigem Suffix in uploads/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:41:16 +02:00
duffyduck 2c7a87ccd3 factory-defaults: HTML-Templates + Import über UI
Erweitert das bestehende Factory-Defaults-Bundle um vier HTML-Standardtexte
(Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz)
und ergänzt den bisherigen CLI-Only-Import um einen Upload-Pfad in der UI.

Backend:
- collectFactoryDefaults() zieht jetzt auch die Whitelist-AppSettings
- exportFactoryDefaults() legt sie als app-settings/app-settings.json ins ZIP
- importFactoryDefaults(buffer) liest die ZIP idempotent ein – upserts pro
  Kategorie, Whitelist-Filter für AppSettings, Anti-Zip-Slip durch basename
  beim PDF-Lookup
- POST /api/factory-defaults/import (multer memoryStorage, max 50 MB,
  settings:update)
- seed-factory-defaults.ts (CLI) gleichermaßen um seedAppSettings() erweitert

Frontend:
- Import-Card in FactoryDefaults.tsx: Datei-Upload statt CLI-Anleitung
- Erfolgs-Box mit Counts pro Kategorie + Warnings (z.B. fehlende PDFs im ZIP)
- Preview zeigt jetzt auch die Anzahl HTML-Templates

Live verifiziert: Round-Trip Export → DELETE privacyPolicyHtml → Import →
Wert (13.6 KB) wieder vollständig hergestellt, Audit-Log zeigt EXPORT +
UPDATE-Eintrag mit Detail-Counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:26:33 +02:00
duffyduck 45f63d1c48 docs: User-DSGVO-/Entwickler-Zugriff-Fix in Erledigt-Liste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:27:18 +02:00
duffyduck 2d3ca28691 fix(users): DSGVO-/Entwickler-Zugriff über User-Update durchreichen
`pickUserUpdate`-Whitelist enthielt `hasGdprAccess` und `hasDeveloperAccess`
nicht – sie wurden vom Mass-Assignment-Schutz aus dem Request entfernt,
bevor sie den Service erreichen konnten. Damit lief `setUserGdprAccess` /
`setUserDeveloperAccess` nie und die zwei versteckten Rollen blieben
unzuweisbar (UI-Checkbox hatte keine Wirkung).

Fix: Beide Felder zur Whitelist hinzugefügt – sie sind keine User-Spalten,
der Service mappt sie auf die DSGVO-/Developer-Rollen.

Bonus: Audit-Log-Diff vergleicht jetzt den Pre-State korrekt (User-Rollen
in `before` mitgeladen + Field-Labels), sonst hätte der jetzt durchkommende
Flag immer einen False-Positive-Change "- → Ja" produziert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:27:02 +02:00
duffyduck 4201a90fd0 docs: HTTPS_ENABLED-Flag in Erledigt-Liste dokumentieren
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:00:56 +02:00
duffyduck 3fb1925a98 security: HTTPS-only-Header per HTTPS_ENABLED-Flag steuern
`upgrade-insecure-requests` (CSP) + HSTS sperrten den Browser bei direktem
http://ip:port-Zugriff aus (ERR_SSL_PROTOCOL_ERROR auf den Vite-Assets,
weil Browser sie via https laden wollte).

Beide Header sind jetzt default OFF und werden nur gesetzt, wenn
HTTPS_ENABLED=true – also sobald ein TLS-Reverse-Proxy (Caddy/Traefik/Nginx)
vor OpenCRM steht. Lokale + non-TLS-Deployments laufen damit ohne Stolperfalle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:00:01 +02:00
duffyduck 63ebf3e75f db: tsx in production-deps + npx-Prefix für seed-Command
Auto-Seed im Container scheiterte mit `ENOENT: tsx prisma/seed.ts`. Zwei
Bugs zusammen:
1. `tsx` war devDependency – durch `npm ci --omit=dev` im Runtime weg.
2. `prisma db seed` spawnt den Befehl über System-PATH; node_modules/.bin
   ist dort nicht enthalten, also war auch das wieder einkopierte tsx
   nicht auffindbar.

Fix: tsx in `dependencies` + Seed-Command auf `npx tsx prisma/seed.ts`
(npx löst lokale .bin-Binaries auf, unabhängig vom Aufrufer-PATH).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:23:06 +02:00
duffyduck 27a0fdbc45 db: Prisma-Migrations-System statt db push (datenerhaltend)
`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>
2026-05-07 17:02:35 +02:00
duffyduck 6f293211a4 docker: Auto-Seed bei leerer DB (kein RUN_SEED-Toggle nötig)
Der entrypoint prüft jetzt nach prisma db push, ob die User-Tabelle
leer ist – wenn ja, wird automatisch geseeded. Damit muss man bei
Erstinstallation nicht mehr daran denken, RUN_SEED=true zu setzen.

Logik:
  RUN_SEED=true  → Force-Seed (auch bei nicht-leerer DB; für Reset)
  User-Count = 0 → Auto-Seed (Default-Verhalten bei leerer DB)
  User-Count > 0 → kein Seed (DB schon initialisiert)

Implementiert via "node -e" mit @prisma/client – kein extra Tool nötig.
Fallback bei Fehlern: User-Count = -1, dann kein Seed.

.env.example aktualisiert: RUN_SEED bleibt 'false' als Default und ist
nur noch für Force-Reseed-Szenarien gedacht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:37:34 +02:00
duffyduck 70e5190594 docker: DATABASE_URL im entrypoint URL-encoden (Sonderzeichen-Bug auf Prod)
Bug auf Prod-System (frische Installation): MariaDB legte 'opencrm'-User
korrekt an, aber Backend bekam "Access denied for user 'opencrm'@...".

Ursache: docker-compose substituierte ${DB_PASSWORD} naiv in
"mysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}". Wenn das
Passwort Sonderzeichen wie $, !, #, @, :, / enthielt, brach das die
URL-Authority-Syntax → Backend connectete mit kaputtem Passwort.

Fix:
- docker-compose.yml: DATABASE_URL aus environment ENTFERNT.
  Stattdessen DB_HOST=db, DB_PORT=3306, DB_NAME, DB_USER, DB_PASSWORD
  als plain env-vars an den Container.
- backend/docker-entrypoint.sh: baut DATABASE_URL beim Start mit
  encodeURIComponent für User+Passwort (via node -e, kein extra Tool
  wie jq nötig). Funktioniert für beliebige Sonderzeichen.

Live-verifiziert:
- 'secret$1!#with@special' → 'secret%241!%23with%40special' (encoded)
- Backend connectet sauber, Login funktioniert
- entrypoint loggt: "[entrypoint] DATABASE_URL aus DB_*-Komponenten
  gebaut (host=db)"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:25:13 +02:00
duffyduck 7d07d52774 docker: App-User statt root für DB-Connection nutzen
Bisher: Backend connectete als root (mit DB_ROOT_PASSWORD) – zu viele
Privilegien (GRANT ALL ON *.*).

Jetzt: Backend nutzt den App-User ${DB_USER}, den MariaDB beim ersten
Container-Start automatisch über MARIADB_USER/MARIADB_PASSWORD anlegt.
Dieser User bekommt von MariaDB direkt GRANT ALL PRIVILEGES auf
${DB_NAME}.* (= nur die OpenCRM-Datenbank, keine anderen Schemas).

Ausreichend für Prisma db push (DDL+DML auf opencrm.*),
nicht ausreichend für Schema-übergreifende Operationen oder
mysql.user-Manipulation – wie es sein soll.

DB_ROOT_PASSWORD bleibt für Adminer / Notfall-Wartung.
.env.example dokumentiert den Mechanismus.

Live-verifiziert:
- Container läuft mit DATABASE_URL=mysql://opencrm:***@db:3306/opencrm
- Prisma db push synced Schema
- Login + alle CRUD-Operationen funktionieren

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:43 +02:00
duffyduck 75c1f9a7bb docker-compose url 2026-05-07 14:58:13 +02:00
duffyduck 62010b05d5 .env: DATABASE_URL aus DB_*-Komponenten zusammenbauen (kein Doppel-Pflegen)
Bisher: DATABASE_URL und die DB_USER/PASSWORD/etc. mussten parallel
gepflegt werden – Werte konnten auseinanderlaufen.

Fix:
- dotenv-expand installiert (löst ${VAR}-Substitution in .env)
- .env.example: DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
- DB_HOST als neue Variable (Default localhost; Container überschreibt zu "db")
- Backend index.ts: dotenvExpand.expand() statt nur dotenv.config()
- Plus Fallback im Code: wenn DATABASE_URL leer aber DB_*-Werte vorhanden,
  baut der Backend-Code die URL selbst zusammen (encodeURIComponent für
  Sonderzeichen im Passwort).

docker-compose.yml setzt DATABASE_URL weiterhin explizit (Container-
internal Hostname "db") und überschreibt damit die Dev-Variante.

Live-verifiziert:
- Dev-Modus: mysql://root:***@localhost:3306/opencrm (substituiert)
- Container: mysql://root:***@db:3306/opencrm (compose explizit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:41:39 +02:00
duffyduck e401c11e40 removed docker veriosn from docker-compose.yml 2026-05-07 14:27:37 +02:00
duffyduck d206b360a6 security: Permissions-Policy-Header setzen (Pentest-Finding)
Helmet setzt Permissions-Policy nicht out-of-the-box. Eigene Middleware,
die alle nicht benötigten Browser-APIs deaktiviert:

  camera, microphone, geolocation, payment, usb, midi, hid,
  accelerometer, gyroscope, magnetometer, ambient-light-sensor,
  battery, idle-detection, encrypted-media, picture-in-picture,
  publickey-credentials-get, screen-wake-lock, xr-spatial-tracking,
  web-share, autoplay, display-capture, sync-xhr, clipboard-read,
  cross-origin-isolated  →  alle =()

Erlaubt für 'self':
  clipboard-write  (CopyButton-Komponenten)
  fullscreen       (falls Vorschau in Vollbild geöffnet wird)

Damit hat eingeschleustes JS keinen Zugriff auf sensible Browser-APIs,
selbst wenn XSS irgendwie durchrutschen sollte.

Live-verifiziert: Header gesetzt + sauber formatiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:25:27 +02:00
duffyduck 096aa63c6f security: Content-Security-Policy aktivieren (Pentest-Finding)
Bug: Stage-1-Kommentar behauptete fälschlicherweise, das Frontend setze
eine CSP via meta-Tag – passierte nie. Helmet-CSP war auf false, kein
CSP-Header im Response. Pentest-Tool hat das richtig moniert.

Fix: Helmet-CSP eingeschaltet mit SPA-tauglichen directives:
  default-src 'self'
  script-src 'self'        (Vite baut Module-Scripts zu separaten Files)
  style-src 'self' 'unsafe-inline'   (Tailwind/inline-styles)
  img-src self/data/blob   (base64-Avatare, blob-PDFs)
  font-src self/data
  connect-src 'self'       (API only)
  frame-ancestors 'none'   (Clickjacking-Schutz, ersetzt X-Frame-Options)
  object-src 'none'        (kein Flash/<object>)
  base-uri 'self'
  form-action 'self'
  upgrade-insecure-requests

Live-verifiziert:
- Frontend index.html hat keine inline-scripts und keine externen
  Resources (Vite-Production-Build) → CSP bricht nichts.
- Header gesetzt: Content-Security-Policy: default-src 'self'; script-src
  'self'; style-src 'self' 'unsafe-inline'; ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:22:01 +02:00
duffyduck 77602bb4ac contracts: VVL (Vertragsverlängerung) als Split-Button neben Folgevertrag
VVL = Vertragsverlängerung beim selben Anbieter (vs. Folgevertrag = i.d.R.
Anbieterwechsel).

Im Gegensatz zu createFollowUpContract wird ALLES kopiert:
- Provider, Tarif, Portal-Username/Passwort (verschlüsselt)
- Preise (basePrice/unitPrice/bonus etc.)
- Notes, Commission, Internet-Zugangsdaten, SIP-Daten, SIM-PINs
- ContractDocuments (1:1, gleiche Datei-Referenz)
- Detail-Tabellen (Energy/Internet/Mobile/TV/CarInsurance) komplett

Berechnet:
- newStartDate = oldStartDate + Vertragslaufzeit (Monate aus
  ContractDuration.code/description geparsed: "24M" / "24 Monate" / "2J")
- newEndDate = newStartDate + Laufzeit
- status = DRAFT (User bestätigt manuell)

NICHT kopiert:
- documentType "Auftragsformular" (das wird neu unterschrieben)
- cancellation*-Felder (alter Cancel-Flow nicht relevant)

Frontend:
- Split-Button: Hauptaktion "Folgevertrag anlegen" + ChevronDown-Pfeil
- Dropdown: "VVL anlegen" mit Bestätigungs-Modal
- Modal zeigt Vorhersage des neuen Startdatums (alter Start +
  Vertragslaufzeit als Hinweis)

History-Einträge wie bei Folgevertrag, mit eigenem VVL-Wording.
Doppel-Schutz: maximal 1 Folge-/VVL-Vertrag pro Vorgänger.

Live-verifiziert:
- Contract #17 (FIBER, 2026-05-01, 24M) → VVL mit Start 2028-05-01 ✓
- Provider/Tarif/Preise/Credentials 1:1 übernommen
- 2 Dokumente kopiert (außer Auftragsformular)
- History-Einträge in beiden Verträgen vorhanden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:12:39 +02:00
duffyduck e763952a84 adminer: Theme-Bootstrap für Designs mit non-Standard CSS-Filenamen
Bug: ADMINER_DESIGN=dracula (oder adminer-dark) zeigte das Default-
Theme. Das offizielle Adminer-Image symverlinkt nur designs/.../adminer.css,
aber manche Designs haben adminer-dark.css, sodass der Symlink ins Leere
lief.

Fix: eigener entrypoint, der das erste .css im gewählten Design verlinkt
(unabhängig vom Filename). Anschließend wird der Original-entrypoint.sh
ausgeführt.

Live-verifiziert: dracula → adminer-dark.css symlink ok, HTML lädt
adminer.css mit 13 KB Theme-CSS.

Plus: .env.example listet alle ~28 verfügbaren Designs als Kommentar
und schlägt 'dracula' als Default vor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:26:20 +02:00
duffyduck 3823f8aa50 backup: SecurityEvent-Tabelle im Backup + Restore mit aufnehmen
Bug: Die in Runde 10 hinzugefügte SecurityEvent-Tabelle (Monitoring) war
nicht im Backup-Service erfasst – beim Backup wurden 43 von 44 Tabellen
gesichert, beim Restore die SecurityEvent-Daten nicht zurückgespielt.

3 Stellen ergänzt:
- tables-Liste (createBackup): SecurityEvent wird jetzt mit findMany abgegriffen
- delete-Order (restoreBackup): securityEvent.deleteMany vor dem Wiederbefüllen
- restoreOrder: SecurityEvent.upsert nach AuditLog

Live-verifiziert: neues Backup enthält SecurityEvent.json mit 152 Einträgen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:19:18 +02:00
duffyduck 0671565433 docker: Runtime auf node:20-slim (Alpine→Debian) – Prisma+TLS-Kompatibilität
Bug: Im Container schlug Prisma + mariadb-Auth fehl.
- Prisma-Engine `linux-musl` braucht libssl.so.1.1 → Alpine 3.19+ hat
  nur openssl 3 → "shared library libssl.so.1.1 not found"
- mariadb-client unter Alpine warf "TLS/SSL error: SSL is required"

Fix: alle Stages (Frontend-build, Backend-build, Runtime) auf
node:20-slim (Debian-bookworm). glibc + openssl 3 ABI-kompatibel,
Prisma generiert linux-debian-Engine korrekt.

Plus: .dockerignore um data/, plesktest/, backup-Klone erweitert
(Build-Context war u.a. wegen MariaDB-Files mit restricted Permissions
nicht lesbar).
Plus: docker-compose.yml: version: '3.8' für docker-compose v1
Kompatibilität.

Live-verifiziert: docker-compose up -d --build → alle 3 Container
healthy, Login funktioniert, alte DB-Daten (3 Kunden, 15 Verträge,
144 SecurityEvents) erhalten via Volume-zu-Bind-Mount-Migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:05:37 +02:00
duffyduck e145edaa90 docker: zentrale .env + Compose mit MariaDB+OpenCRM+Adminer + Bind-Mounts
Big Move: vom backend-only-Setup zum vollständigen Container-Stack.

📁 Neue Struktur
- /.env (lokal, nicht getrackt) – zentrale Konfiguration für Dev + Docker
- /.env.example – Template mit allen Variablen
- /data/{db,uploads,factory-defaults,backups}/ – Bind-Mounts statt Volumes
  (auf Wunsch: Daten bleiben im Projektverzeichnis)
- /backend/Dockerfile – Multi-Stage Build (Frontend + Backend)
- /backend/docker-entrypoint.sh – wartet auf DB, prisma db push, optional seed

🐳 docker-compose.yml (neu konsolidiert)
- mariadb 10.11 mit Bind-Mount ./data/db
- opencrm-app (Backend serviert Frontend statisch in production)
- adminer mit Theme pepa-linha-dark als DB-UI
- Ports + Pfade + Secrets alle aus .env

🔧 Backend
- index.ts dotenv-Loader: lädt zuerst Root /.env, dann backend/.env als
  Fallback. Funktioniert nahtlos für npm run dev und für Container.
- backend/.env.example als Legacy-Fallback dokumentiert

📝 README
- Quick-Start mit Docker als empfohlener Default (3 Befehle)
- Tabelle der Daten-Verzeichnisse
- Hinweis auf RUN_SEED=true beim ersten Start

⚙ Konfigurierbar via .env
- OPENCRM_PORT (Backend extern), ADMINER_PORT (DB-UI), DB_PORT
- Daten-Pfade (DATA_DIR, DB_DATA_DIR, UPLOADS_DIR etc.)
- DB_NAME/USER/PASSWORD, JWT_SECRET, ENCRYPTION_KEY
- ADMINER_DESIGN (Theme-Auswahl)

Hinweis: Vor dem ersten `docker compose up -d` muss das laufende
`npm run dev`-Backend gestoppt werden (Port + DB-Conflict). Das alte
Volume `opencrm_mariadb_data` bleibt unangetastet als Notfall-Backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:53:19 +02:00
duffyduck 3b4a680326 chore: backend/.env aus Git entfernt + .gitignore klargestellt
backend/.env war seit "first commit" getrackt (mit echten Secrets:
JWT_SECRET, ENCRYPTION_KEY, DB-Password). Das Pattern .env war zwar
in .gitignore, wirkte aber nicht rückwirkend.

- git rm --cached backend/.env (Datei bleibt lokal)
- backend/.gitignore + frontend/.gitignore: explizite !.env.example
  Whitelist zur Klarstellung
- Neue Root-.gitignore mit gemeinsamen Patterns (Env, OS, IDE, Logs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:35:00 +02:00
duffyduck 389b878dbd Monitoring: Threshold-Debounce auf sliding-window (statt floor-to-hour)
Bug: zweimal CRITICAL-Alert für dieselbe Brute-Force-Erkennung kam an.

Ursache: detectThresholds() hat als Cutoff für den "existing"-Check
floor(now, hour) genutzt. Bei Stundenwechsel resettete der Bucket
und der nächste Cron-Lauf fand nichts mehr "in der aktuellen Stunde"
→ erzeugte zweites SUSPICIOUS-Event → zweite Mail.

Fix: gleitendes 60min-Fenster (now - 60min). Pro IP gibt es jetzt
zuverlässig max. 1 CRITICAL-Alert pro Stunde, unabhängig von der
absoluten Uhrzeit.

Live-verifiziert in DB: zwei Alerts kamen um 07:41 und 08:00 –
genau das Pattern, das der Stunden-Reset erzeugt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:11:52 +02:00
duffyduck 96feb6a663 v1.1.0: Production-readiness Release
- Backend + Frontend package.json: 1.0.0 → 1.1.0
- README:
  - Version-Badge oben
  - Features-Liste erweitert (Auto-Status, Monitoring, Hardening)
  - Neue "Production-Deployment"-Sektion mit Pflicht-Env, Reverse-Proxy-
    Hinweis, Default-Passwort-Warnung und Verweisen auf TESTING.md +
    SECURITY-HARDENING.md
  - Changelog für 1.1.0 + 1.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:42:56 +02:00
duffyduck 49905aa97e Monitoring: Pagination mit Seitenzahlen + Anfang/Ende-Buttons
Vorher: nur "Zurück / Weiter". Jetzt:
[«] [‹] [1] [2] [3] [4] [5*] [6] [7] [8] [9] [10] [›] [»]

10 Seitenzahlen-Buttons, current centered (clamped an Anfang/Ende).
Zusätzlich Doppelpfeile für erste/letzte Seite. Kompakt + verständlich
auch bei 50+ Seiten.

Helper paginationWindow() rechnet das Fenster aus, sodass bei
totalPages <= 10 alle gezeigt werden, sonst current ungefähr mittig
mit Clamp an die Ränder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:36:40 +02:00
duffyduck e2fdb069ac Monitoring UX: Log leeren + PageSize wählbar
- Backend: DELETE /api/monitoring/events (settings:update). Optional
  ?olderThanDays=N – nur Events älter als N Tage löschen.
  Hinterlässt selbst einen Audit-Eintrag "Log geleert: X Einträge"
  mit User-E-Mail + IP, damit der Vorgang nachvollziehbar bleibt.
- Frontend: "Log leeren"-Button öffnet Bestätigungs-Modal mit
  optionalem "älter als X Tage"-Filter. Roter Bestätigungs-Button.
- Frontend: PageSize-Selector (10/25/50/100/200) neben dem Header.
  Wechsel setzt automatisch zurück auf Seite 1.

Live-verifiziert: Clear löscht 10 Events, schreibt 1 Audit-Event,
PageSize=5 wird in pagination respektiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:31:53 +02:00
duffyduck 0cf3dd6a7b Security-Hardening Runde 10: Security-Monitoring + Alerting
Defense-in-Depth für alles, was in den ersten 9 Runden nicht durch Code
verhindert wurde: zumindest gesehen + alarmiert werden.

📊 SecurityEvent-Tabelle (Prisma)
- Type/Severity/IP/User/Endpoint + Indexen für Filter+Threshold-Detection
- Trennt sich vom AuditLog: AuditLog ist forensisch + hash-gekettet,
  SecurityEvent ist optimiert für Realtime-Alerting + Aggregation.

🪝 Hooks an kritischen Stellen
- Login (Success/Failed) – auth.controller
- Logout, Password-Reset (Request + Confirm) – auth.controller
- Rate-Limit-Hit – middleware/rateLimit
- IDOR-403 – utils/accessControl (canAccessCustomer / canAccessContract)
- SSRF-Block – emailProvider.controller (test-connection + test-mail-access)
- JWT-Reject (alg=none, expired, manipuliert) – middleware/auth

🚨 Threshold-Detection + Alerting (securityAlert.service.ts)
- Cron jede Minute: prüft Brute-Force-Patterns je IP
  - 10× LOGIN_FAILED in 60 min  → CRITICAL Brute-Force-Verdacht
  -  5× ACCESS_DENIED in 5 min  → CRITICAL IDOR-Probing-Verdacht
  -  3× SSRF_BLOCKED in 60 min  → CRITICAL SSRF-Probing
  -  3× TOKEN_REJECTED HIGH in 5 min → CRITICAL JWT-Manipulation
- CRITICAL-Events: Sofort-Alert per E-Mail (debounced)
- Cron stündlich: Digest mit HIGH+MEDIUM-Events (wenn aktiviert)
- Sofort-Alert + Digest laufen über System-E-Mail-Provider
  (gleicher Pfad wie Geburtstagsgrüße, Passwort-Reset)

🖥 Frontend: Settings → "Sicherheits-Monitoring"
- Alert-E-Mail-Adresse + Digest-Toggle
- Test-Alert-Button + Digest-jetzt-Button
- Stats-Cards pro Severity (CRITICAL/HIGH/MEDIUM/LOW/INFO)
- Filter (Type/Severity/Search/IP) + Pagination
- Auto-Refresh alle 30 s
- Verlinkt aus Settings-Übersicht (settings:read Permission)

🧪 Live-verifiziert
- Login-Fehlversuch → LOGIN_FAILED Event
- Portal probt 4× fremde Customer-IDs → 4× ACCESS_DENIED
- SSRF-Probe (169.254.169.254) → SSRF_BLOCKED Event
- 12× LOGIN_FAILED simuliert → Cron erzeugt CRITICAL nach ≤60s
- CRITICAL-Sofort-Alert binnen 30s zugestellt
- Test-Alert-Button: E-Mail zugestellt
- Hourly-Digest mit 5 Events: E-Mail mit Tabelle zugestellt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:25:47 +02:00
duffyduck 45fe270a38 Security-Hardening Runde 9: Diminishing returns
Letzte Runde – nichts Kritisches mehr gefunden, was den Aufwand wert
wäre. Diminishing returns sind erreicht.

🔧 npm audit fix
- 9 Vulnerabilities → 1 (lodash, path-to-regexp, undici, minimatch
  transitiv geupdatet via package-lock.json).
- Verbliebene nodemailer-Vuln braucht Major-Update v6→v8 (breaking).
  Wir setzen die betroffenen Felder (envelope.size, transport name)
  nicht aus User-Input – als v1.1-Item dokumentiert.

🔍 Audit-Log-Hash-Chain
- War vor Runde 9 invalid (~350 Einträge) durch frühere Schema-
  Migrationen, nicht durch Manipulation.
- rehashAll repariert; integrity-check verifiziert die Chain wieder.
  Verfahren funktioniert – wäre eine echte Manipulation, würde sie
  auffallen.

🟢 Geprüft + sauber (kein Bug)
- From-Header-Injection in smtpService (Stage 3 deckt das schon ab).
- Concurrent Password-Reset Token-Reuse (atomares Delete).
- Frontend localStorage Token-Pattern (Standard-SPA, XSS-resistent durch
  DOMPurify in allen Render-Stellen).

📋 Bewusst NICHT gemacht (in HARDENING.md dokumentiert)
- Authenticated Rate-Limit (Aufgabe vom Reverse-Proxy).
- JWT in HttpOnly-Cookie statt localStorage (CSRF-Token-System nötig).
- nodemailer Major-Update.

Der Block "Wann ist dicht dicht?" in SECURITY-HARDENING.md formuliert
die Endkriterien: 5 Punkte erfüllt, was bleibt sind zero-days +
Server-Misconfig in Production – beides nicht durch Code-Änderung
lösbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:47:20 +02:00
duffyduck 73f271ae03 docs: todo.md von backend/ nach docs/ verschoben
todo.md gehört thematisch zur Doku, nicht zum Backend-Code.
Interne Pfade (TESTING.md, SECURITY-*.md) auf relative ./-Pfade angepasst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:23:27 +02:00
duffyduck 4385ae575d docs: Security-Hardening in eigene MD ausgelagert + Live-Tabellen
- Neue docs/SECURITY-HARDENING.md mit der ganzen 8-Runden-Story inkl.
  aller Live-Test-Tabellen (Runden 4–8 jeweils mit Vorher/Nachher),
  geprüft+sauber-Liste, Trade-offs und Deployment-Checkliste.
- backend/todo.md: kompletter Hardening-Block raus, ersetzt durch
  knappen Verweis (250 statt 421 Zeilen). todo.md ist jetzt wieder
  echte Todo-Liste, nicht Security-Doku.
- docs/SECURITY-REVIEW.md: Banner oben, der auf HARDENING.md verweist
  (REVIEW.md bleibt als ausführliche Doku der ersten 2 Runden).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:15:38 +02:00
duffyduck 6b804cdc82 Security-Hardening Runde 8: DNS-Rebinding + Per-File-Ownership
Loose Ends aus Runde 5/7 abgearbeitet.

🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
  gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
  servername=hostname an die Mail-Services. Damit kann ein zweiter
  DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
  umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
  erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.

🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
  mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
  invoices, cancellation-*, authorizations, business-/commercial-/
  privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
  Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
  er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
  intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...

Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
  Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
  DNS-Lookup geblockt; gmail.com (legitim) geht durch.

Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
  invasiv für <a href>-Flows ohne JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:59:19 +02:00
duffyduck df6eb9724d Security-Hardening Runde 7: SSRF-Schutz + Logout-Endpoint
🛡 SSRF-Schutz in test-connection / test-mail-access
- Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder
  Connections zu Cloud-Metadata-Endpoints (169.254.169.254,
  metadata.google.internal etc.) auslösen. Internal-Port-Scan
  über Timing-Differenzen war messbar.
- Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16,
  0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata,
  IPv6-Link-Local und Cloud-Metadata-Hostnames.
  Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix-
  Setups sollen weiter funktionieren.

🔒 Logout-Endpoint POST /api/auth/logout
- Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt.
  Auth-Middleware prüft das Feld bereits und lehnt Tokens mit
  iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter"
  JWT bis Expiry (7d) gültig.

Live-verifiziert:
- 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400
- 127.0.0.1 (Plesk-Fall) weiter erlaubt
- /me vor Logout 200, nach Logout 401 "Sitzung ungültig"

Geprüft + sauber (Runde 7, kein Bug):
- Public Consent (122-bit Random-UUID nicht brute-force-bar)
- Magic-Bytes-Bypass beim Upload
- PDF manualValues Injection (keine HTML-Render-Surface)
- Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:47:26 +02:00
duffyduck 0c0cecdbbd Security-Hardening Runde 6: Customer-Liste-Leak + XFF-Bypass + Vollmacht-Validation
Tiefer Live-Pentest deckte 3 weitere Schwachstellen:

🚨 CRITICAL: GET /api/customers leakte komplette Kundendatenbank
- Stage-4 hatte canAccessCustomer auf den Single-Endpoint angewendet,
  der List-Endpoint hatte nur den Daten-Sanitizer (filtert Passwort-Hashes)
  aber keinen Portal-Filter. Folge: Portal-Kunde sah ALLE Kunden mit Namen,
  E-Mails, customerNumber etc. – DSGVO-relevant.
- Fix: getCustomers filtert für Portal-User auf eigene + vertretene IDs.

🚨 HIGH: Rate-Limit-Bypass via X-Forwarded-For
- `trust proxy = 1` hat jedem XFF-Wert vertraut. 12+ Logins mit
  rotierender XFF-IP gingen ohne 429 durch.
- Fix: `trust proxy = 'loopback'` – XFF nur noch von 127.0.0.1 / ::1
  akzeptiert (= lokaler Reverse-Proxy).
- Plus: LISTEN_ADDR-Default 127.0.0.1 in Production, damit das Backend
  nicht von außen direkt ansprechbar ist.

🛡 MEDIUM: Self-Grant + Existence-Disclosure in toggleMyAuthorization
- Portal-User konnte:
  a) sich selbst Vollmacht erteilen (customerId=representativeId=1)
  b) Authorization-Records für nicht-existierende customerIds anlegen
     (scheitert erst am DB-Constraint mit vollem Prisma-Stack-Leak)
  c) Customer-IDs durch 404-vs-403-Differenzen enumerieren.
- Fix: Self-Grant 400. Existenz + aktive CustomerRepresentative-Beziehung
  in einem Query – Non-Existent / Non-Related geben identisch 403.
  Prisma-Error-Stacks generisch ersetzt.

Live-verifiziert: Customer-Liste filtert, Self-Grant 400, Existence-Probing
dicht.

Geprüft + sauber (Runde 6, kein Bug):
- Prototype Pollution Login-Body
- HTTP-Method-Override-Header
- Path-Traversal Backup-Name (Regex blockt)
- Developer-Routes existieren nicht
- Email-Endpoints mit fremder StressfreiEmail-ID → 403
- /api/customers/:id GET liefert 403 statt 404 (kein Leak)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:14:20 +02:00
duffyduck 35745ce3bb Security-Hardening Runde 5: Hack-Das-Ding (DSGVO-GAU + Timing + XSS)
Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents.

🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar
- express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL
  sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten,
  Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden
  ohne Login geladen.
- Fix: authenticate-Middleware vor static-Handler (req.query.token
  unterstützung war schon da, jetzt aktiv genutzt).
- Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte
  /api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail,
  ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard).

🚨 HIGH: Login-Timing User-Enumeration
- bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms
  vs 10ms Differenz, Email-Enumeration trivial messbar.
- Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy-
  Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus
  Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und
  Echt-Hash-Cost zusammenpassen.
- Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid)
  – Side-Channel dicht.

🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML
- 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify
  (PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint).
  Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden-
  Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite.
- Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config.

🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints
- canAccessContract jetzt in: uploadContractDocument,
  deleteContractDocument, handleContractDocumentUpload (Kündigungs-
  Letter+Confirmation), handleContractDocumentDelete,
  saveAttachmentAsContractDocument.
- Defense-in-Depth: aktuell durch requirePermission abgesichert,
  schützt auch gegen künftige Staff-Scoping-Rollen.

Offen für v1.1:
- Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup
  welche Ressource zur Datei gehört)
- TipTap-Link-Tool javascript:-Protokoll blockieren
- Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:21:37 +02:00
duffyduck dea2da0271 Vertragsstatus-Trigger: Datum beim Upload miterfassen
Beim automatischen Status-Wechsel wird jetzt auch das passende Datum gesetzt,
damit Status und Datumsfeld konsistent sind (Cockpit-Warnung "Datum fehlt"
verschwindet sofort nach Upload).

Backend:
- Upload-Handler für Kündigungsbestätigung(s-Optionen) nimmt optional
  `confirmationDate` aus multipart an, speichert als
  cancellationConfirmationDate / cancellationConfirmationOptionsDate.
  Fallback: heute (nur falls Feld noch leer war).
- maybeActivateOnDeliveryConfirmation nimmt optional deliveryDate, setzt
  Contract.startDate falls leer. Fallback: heute.

Frontend:
- ContractDetail: neues kleines Modal beim Kündigungsbestätigungs-Upload
  fragt das Bestätigungs-Datum ab (Default: heute oder bereits gesetzter
  Wert). Der bestehende inline-Datums-Editor bleibt für spätere Korrekturen.
- ContractDocumentsSection: Datums-Input erscheint conditional im
  Upload-Bereich, sobald Typ "Lieferbestätigung" gewählt ist.
- SaveAttachmentModal (E-Mail-Anhang → Vertragsdokument): gleicher
  Datums-Input conditional für "Lieferbestätigung".
- API-Methoden uploadCancellationConfirmation / uploadDocument /
  saveAttachmentAsContractDocument nehmen optional Datum entgegen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:40:04 +02:00
duffyduck 0a757d8e47 Auto-Vertragsstatus: Lieferbestätigung hochladen → DRAFT auf ACTIVE
Ergänzung zum Cancellation-Trigger: wenn ein ContractDocument mit
documentType "Lieferbestätigung" hochgeladen wird und der Vertrag aktuell
DRAFT ist, wird er automatisch auf ACTIVE gesetzt (+ Audit-Log).

Greift an beiden Upload-Pfaden:
- POST /api/contracts/:id/documents  (Direkt-Upload via ContractDetail)
- POST /api/emails/:id/attachments/:filename/save-as-contract-document
  (Email-Anhang als Vertragsdokument speichern)

Vergleich case-insensitive + getrimmt auf "lieferbestätigung".
Andere Typen (Auftragsformular etc.) lösen keinen Wechsel aus. Nicht-DRAFT-
Verträge (ACTIVE/CANCELLED/EXPIRED/DEACTIVATED) bleiben unverändert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:20:30 +02:00
duffyduck 4e680a36e7 Auto-Vertragsstatus: nightly EXPIRED + Kündigungsbestätigung → CANCELLED
- Neuer Scheduler (02:00 + Catch-up 60s nach Start): alle ACTIVE-Verträge mit
  endDate < heute werden auf EXPIRED umgestellt. Audit-Log pro Vertrag.
- Upload cancellationConfirmationPath: Vertrag wechselt von ACTIVE → CANCELLED
  (mit Audit-Log). "Options"-Upload triggert bewusst nicht, da für
  Vertragsänderungen gedacht, nicht für echte Kündigungen.
- Keine neuen Statuswerte. "Kündigung gesendet vs. bestätigt" bleibt über die
  getrennten Felder cancellationSentDate / cancellationConfirmationDate lesbar,
  Status bleibt bis zur Bestätigung auf ACTIVE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:08:58 +02:00
duffyduck a129781035 docs(todo): Live-verifiziert-Tabelle für Security-Runde 4 ergänzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:01:26 +02:00
duffyduck 4ca91eb710 Security-Hardening Runde 4: 9 Live-IDORs + Error-Handler
Live-Pentest gegen Dev-Server mit Portal-Token deckte auf, dass customer.* und
gdpr.* Endpoints nur den Data-Sanitizer, aber KEINEN canAccessCustomer-Check
hatten. Ein Portal-Kunde mit customers:read konnte per ID-Manipulation komplette
Fremddatensätze auslesen.

- customer.controller.getCustomer + getAddresses + getBankCards + getDocuments
  + getMeters + getRepresentatives + getPortalSettings: canAccessCustomer
- gdpr.controller.getCustomerConsents + getAuthorizations + checkConsentStatus:
  canAccessCustomer
- createAddress/createBankCard/createDocument/createMeter (customerId aus URL):
  canAccessCustomer (Defense-in-Depth – wird aktuell schon per Permission
  geblockt, aber im Controller ungeschützt)
- Global Error-Handler: err.status respektieren (PayloadTooLargeError → 413
  "Anfrage zu groß", SyntaxError → 400 "Ungültiges JSON" statt pauschal 500)

Live-verifiziert:
  ✓ /api/customers/4 als Portal → 200 VORHER, 403 NACHHER
  ✓ 9 andere IDOR-Endpoints gleiches Muster
  ✓ Eigene Daten (/api/customers/1) weiter 200
  ✓ 12 MB Body → 413, malformed JSON → 400

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:59:37 +02:00
duffyduck 8aead8c2f6 Security-Hardening Runde 3: JWT, trust-proxy, weitere IDORs, Attachment-Härtung
- JWT-Algorithmus fest auf HS256 (Defense-in-Depth gegen alg-confusion)
- app.set('trust proxy', 1) – Rate-Limiter wirkt jetzt auch hinter Reverse-Proxy
- IDOR-Fix: Invoice-ECD-Endpoints + PDF-Template-Generierung (canAccessContract/ECD)
- Email-Anhang-Download: Content-Type-Safelist, SVG nie inline, nosniff, Filename-CRLF-Sanitize
- Provider/Tariff-GET-Routen: requirePermission('providers:read') (Portal-Kunden raus)
- SMTP-Header-Injection zentral in sendEmail blockiert (schützt alle Caller)
- bcrypt-Cost 10 → 12 (OWASP 2026)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:38:25 +02:00
duffyduck 301aafffd1 chore: helmet korrekt in backend/package.json statt Root
Beim install-Befehl war ich versehentlich im Repo-Root statt im backend/,
wodurch helmet in einem /package.json landete (das ins Repo wollte) statt
in backend/package.json. Jetzt sauber installiert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:12 +02:00
duffyduck 81f0e89058 Security-Hardening Runde 2: Zip-Slip, Mass Assignment, weitere IDORs, Path-Traversal
Nach der ersten Runde habe ich parallel 3 Audit-Agents auf die Codebase
angesetzt. Die fanden noch eine Menge: Zip-Slip, Mass Assignment inkl.
Privilege Escalation, 13 weitere IDOR-Stellen, 2x Path-Traversal.

Alles gefixt. Details + Angriffsvektoren in docs/SECURITY-REVIEW.md.

🔴 KRITISCH gefixt:

1. Zip-Slip im Backup-Upload: extractAllTo() entpackte bösartige ZIPs ohne
   Pfad-Validierung. Ein Angreifer mit Admin-Zugang hätte mit einem ZIP
   mit Entries wie ../../etc/crontab das ganze Filesystem überschreiben
   können. Jetzt wird jeder ZIP-Entry einzeln validiert (path.resolve,
   starts-with-Check). Absolute Pfade + Null-Bytes werden abgelehnt.

2. Mass Assignment bei Customer/User Controllers:
   - updateCustomer/createCustomer: req.body ging komplett an Prisma.
     Angreifer konnte portalPasswordHash, portalPasswordResetToken,
     consentHash, customerNumber direkt setzen.
   - updateUser/createUser: roleIds und isActive waren übernehmbar.
     **Privilege Escalation**: normaler Mitarbeiter konnte sich Admin-Rechte
     durch PUT /users/:id mit {"roleIds":[1]} geben, oder andere User
     deaktivieren.
   Fix: Neue Whitelist-Helper pickCustomerCreate/Update, pickUserCreate/Update
   in utils/sanitize.ts. Nur erlaubte Felder werden durchgelassen.

3. IDOR bei 13 weiteren Endpoints (neben denen aus Runde 1):
   - GET /meters/:meterId/readings
   - GET /emails/:emailId/attachments/:filename
   - GET /emails/:emailId/attachments (Liste)
   - GET /customers/:customerId/emails
   - GET /contracts/:contractId/emails
   - GET /emails/:id (einzelne Email)
   - GET /stressfrei-emails/:id (leakte emailPasswordEncrypted)
   - weitere…
   Fix: accessControl.ts ausgebaut um canAccessAddress, canAccessBankCard,
   canAccessIdentityDocument, canAccessMeter, canAccessStressfreiEmail,
   canAccessCachedEmail. In allen betroffenen Endpoints angewendet.

🟡 WICHTIG gefixt:

4. Path-Traversal bei Backup-Name (GET /settings/backup/:name/*): req.params.name
   wurde ohne Filter in path.join. Neuer isValidBackupName() erlaubt nur
   [A-Za-z0-9_-]+ ohne "..".

5. Path-Traversal bei GDPR-Proof-Download: proofDocument-Pfad aus DB wurde
   ohne Validation gejoined. Jetzt path.resolve + starts-with-uploads-Check.

Neue/erweiterte Files:
- backend/src/utils/accessControl.ts - 6 neue can-Access-Helper
- backend/src/utils/sanitize.ts - 4 neue Whitelist-pick-Helper
- docs/SECURITY-REVIEW.md - Runde 2 dokumentiert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:59:28 +02:00
duffyduck 1c46d7345c Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure
Umfassender Security-Review vor öffentlichem Deployment.
Detaillierter Report in docs/SECURITY-REVIEW.md.

🔴 KRITISCHE FIXES:

1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env),
   in Production per default komplett aus (gleiche Origin erzwingt Browser).

2. Keine Security-Headers → helmet-Middleware hinzugefügt.
   X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP.

3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob
   JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind,
   sonst Fail-Fast mit klarer Fehlermeldung.

4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read'
   konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK,
   Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen).
   Neuer Helper canAccessContract() in utils/accessControl.ts in allen
   betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten.

5. XSS via Email-Body. email.htmlBody wurde ungefiltert via
   dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script>
   schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify
   sanitized: verbietet script/iframe/form/inline-handler, erlaubt
   normale Formatierung + Bilder.

6. Customer-API leakte sensible Felder:
   - portalPasswordHash (bcrypt-Hash)
   - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar)
   - portalPasswordResetToken (gültig 2h)
   Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers.
   Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige),
   alle anderen Rollen nicht.

🟡 WICHTIGE FIXES:

7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld
   Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt.
   Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden
   dadurch invalidiert.

8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar).

Neue Files:
- backend/src/utils/accessControl.ts - IDOR-Schutz
- backend/src/utils/sanitize.ts - Response-Sanitizer
- docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:06:16 +02:00
duffyduck 8fc050a282 docs: TESTING.md mit Check-Listen für Security + Email-Log-System
Strukturierter Test-Katalog für manuelle Abnahmetests vor einem Release.

Security-System (10 Abschnitte):
- Login + Rate-Limiting
- Passwort-Reset-Flow (Mitarbeiter + Portal)
- Rate-Limiting Passwort-Reset
- Berechtigungen (RBAC)
- Portal-Isolation (DSGVO-kritisch)
- Session-Invalidation
- Audit-Log
- DSGVO-Features (Export, Löschanfragen, Einwilligungen)
- Verschlüsselte Credentials
- DSGVO-Einwilligung sperrt Tabs für Mitarbeiter

Email-Log-System (5 Abschnitte):
- Email-Log-Seite (UI, Filter, Suche, Pagination)
- Alle 6 Kontexte durchspielen:
  consent-link, authorization-request, customer-email,
  birthday-greeting, birthday-greeting-auto, password-reset
- Fehlgeschlagener Versand wird geloggt (Test mit falschem SMTP-Passwort)
- Details-Modal mit SMTP-Details
- Automatisches Logging (Kontext, triggeredBy)

Außerdem: Neue Kontexte in EmailLogs.tsx CONTEXT_LABELS ergänzt
(birthday-greeting, birthday-greeting-auto, password-reset).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:34 +02:00
duffyduck 0764bc6ddf Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße
Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0:

## 1. Passwort vergessen-Flow

Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch
für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht
mehr manuell eingreifen.

- Neues Link "Passwort vergessen?" auf Login-Seite
- PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal)
- PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen)
- Token ist 2 Stunden gültig, dann muss neu angefordert werden
- Token ist kryptografisch sicher (crypto.randomBytes(32))
- User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert
- Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt
  (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war

DB:
- User.passwordResetToken + passwordResetExpiresAt
- Customer.portalPasswordResetToken + portalPasswordResetExpiresAt

## 2. Rate-Limiting gegen Brute-Force

Mit express-rate-limit:
- Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit.
- Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern)

Sowohl Mitarbeiter-Login als auch Portal-Login geschützt.

## 3. Auto-Geburtstagsgrüße per Cron

Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der
ihn wirklich abschickt. Jetzt:

- Läuft täglich um 08:00 Uhr
- Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Nur Email-Kanal (Messenger brauchen Browser-Klick)
- Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird
  beim nächsten Boot nachgeholt
- lastBirthdayGreetingYear verhindert Doppelversand

Dependencies: node-cron, @types/node-cron, express-rate-limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:27 +02:00
duffyduck 8d113f4c6b Backup/Restore: alle neuen Tabellen erfasst (43 Tabellen insgesamt)
Das Backup- und Restore-System kannte noch nicht alle Tabellen, die im Lauf
der letzten Wochen hinzugekommen sind. Kritischer Datenverlust im Ernstfall!

Neu im Backup + Restore:
- PdfTemplate (PDF-Auftragsvorlagen + Feldzuordnungen)
- ContractMeter (Zähler-Vertrag-Zuordnungen mit Zeiträumen)
- ContractDocument (flexible Vertragsdokumente: Auftragsformular, Lieferbestätigung ...)
- RepresentativeAuthorization (Vollmachten zwischen Kunden)
- CustomerConsent (DSGVO-Einwilligungen pro Kunde)
- DataDeletionRequest (DSGVO-Löschanfragen)
- EmailLog (SMTP-Sendeprotokoll)
- AuditRetentionPolicy (Aufbewahrungsfristen pro Ressourcentyp)
- AuditLog (vollständiges Änderungsprotokoll)

Außerdem:
- prisma/backup-data.ts: komplett neu strukturiert, korrekte Level-Hierarchie,
  nutzt aktuelles Schema (Provider statt EnergyProvider/TelecomProvider,
  InternetContractDetails statt TelecomContractDetails etc.)
- prisma/restore-data.ts: Boilerplate durch generische restoreTable()-Helper
  ersetzt – von 487 auf ~240 Zeilen
- backup.service.ts: neue Tabellen in createBackup, restoreOrder und
  deleteMany-Liste nachgetragen (Service bleibt sonst wie er ist)

Test-Backup erfolgreich: 4420 Datensätze in 37 aktiven Tabellen gesichert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:53:26 +02:00
duffyduck fd480113d0 docs: Plesk API-Key - Hinweis dass 0.0.0.0 nicht funktioniert
-ip-address 0.0.0.0 bei plesk bin secret_key --create funktioniert NICHT,
um alle IPs zu erlauben. Stattdessen muss der Parameter komplett weggelassen
werden. Warnhinweis in der README ergänzt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:44:31 +02:00
duffyduck 95bf118fc2 docs: Plesk-API-Key-Anleitung in README ergänzt
Neuer Abschnitt erklärt Schritt-für-Schritt wie man den API-Key in Plesk anlegt:
- Variante 1: Über die Plesk-Oberfläche (Mein Profil → API-Token)
- Variante 2: Über SSH (plesk bin secret_key --create)
- Hinweise zur REST-API-Extension (falls API-Key-Button fehlt)
- Firewall-Konfiguration für Port 8443

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:27:54 +02:00
duffyduck 075c095b8e todo: SaaS-Ausbau-Plan (Instance-per-Customer + GoCardless) dokumentiert
Detaillierter Plan für späteren SaaS-Umbau festgehalten, damit wir beim
nächsten Mal nicht neu planen müssen:

- Architektur: Instance-per-Customer (Weg C)
  → keine Multi-Tenancy im Code, pro Kunde eigene Docker-Instanz + DB
  → Isolation statt tenantId-Filter, DSGVO-freundlich
- Admin-Portal (separate App) für Provisioning, Kundenverwaltung, Billing
- Abrechnung über GoCardless (SEPA + Kreditkarte), 30-Tage-Trial
- Plesk-Integration nutzen, KEIN eigener Mailserver
- Technische Bausteine, Provisioning-Flow, Zeitschätzung (~3-4 Wochen)

Status: erstmal nur auf der Todo, nicht angefangen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:24:37 +02:00
duffyduck 3fa1dce2dc UX: Label-Feld aus Provider-Formular entfernen
Problem: Das 'Bezeichnung für Kunden-E-Mails'-Feld (UI-Label) war irreführend.
Ein User hat dort die Domain 'stressfrei-meyer.xyz' eingetragen statt ins
Domain-Feld – das eigentliche Domain-Feld blieb unverändert, und das Label
zeigte dann unpassend die Domain-Schreibweise.

Fix: Das Label-Feld ist in 99% aller Fälle nicht nötig, weil es automatisch
aus der Domain abgeleitet wird (stressfrei-wechseln.de → Stressfrei-Wechseln).
Der Edge-Case 'komplett anderer Anzeigetext als aus Domain ableitbar' kommt
selten vor und kann später bei Bedarf über direkten DB-Zugriff/Developer-Panel
gesetzt werden.

Das Schema-Feld bleibt erhalten (für zukünftige Erweiterungen), nur das
Formular-Feld ist weg. Stattdessen Hinweistext unter dem Domain-Feld:
'Wird auch für die Kunden-E-Mail-Adressen genutzt (z.B. name@...)'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:00:41 +02:00
duffyduck b7d3654b72 Fix: Provider-Domain greift sofort + Domain-Validierung
Problem: Nach dem Ändern der Provider-Domain blieb die alte Domain
(stressfrei-wechseln.de) im Adress-Hinzufügen-Dialog bestehen, weil der
Frontend-Hook useProviderSettings() einen 5-Minuten staleTime hat und
nicht invalidiert wurde.

Fix:
- In allen Provider-Mutations (create/update/delete) wird jetzt auch
  'email-provider-public-settings' invalidiert → Domain & Label greifen
  sofort in allen Komponenten

Zusätzlich Domain-Validierung eingebaut:
- Frontend: pattern am Input + Live-Fehlermeldung
  Format: name.tld (mit Subdomains erlaubt, z.B. mail.meine-firma.de)
  Input auto-lowercase + trim
- Backend: validateDomain() in createProviderConfig/updateProviderConfig
  Wirft Error mit sprechender Meldung bei ungültigem Format
- Schützt vor Versehen im UI + direkten API-Aufrufen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:51:16 +02:00
duffyduck cdde7b4ab7 Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider
Alle hardcoded Referenzen auf 'stressfrei-wechseln.de' und 'Stressfrei-Wechseln'
durch dynamische Werte aus der EmailProviderConfig ersetzt. Notwendig für
Multi-Mandanten-Betrieb, wenn das CRM an Dritte vermietet wird.

Schema:
- Neues Feld EmailProviderConfig.customerEmailLabel (String?)
- Wenn leer, wird Label aus Domain abgeleitet ('stressfrei-wechseln.de' → 'Stressfrei-Wechseln')

Backend:
- Neuer Endpoint GET /api/email-providers/public-settings liefert { domain, customerEmailLabel }
- Neue Service-Funktionen: getProviderPublicSettings(), deriveLabelFromDomain()
- create/updateProviderConfig erweitert um customerEmailLabel

Frontend:
- Neuer Hook useProviderSettings() mit Auto-Caching
- Neues Eingabefeld 'Bezeichnung für Kunden-E-Mails' im Provider-Modal
- Dynamische Domain-Suffix im Adress-Hinzufügen-Dialog (@<domain>)
- Tab-Label 'Stressfrei-Wechseln' im Kunden-Detail → dynamisch
- 'Stressfrei-Wechseln Adresse' in ContractForm → dynamisch
- '(Stressfrei-Wechseln)' Badge in ContractDetail → dynamisch
- 'Stressfrei-Wechseln E-Mail' im Generate-Modal → dynamisch
- Leere-Zustand-Meldungen in Tab und E-Mail-Client → dynamisch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:43:19 +02:00
duffyduck cf4370c905 Toast-Benachrichtigungen bei IMAP-Sync- und SMTP-Send-Fehlern
Bisher blieb ein fehlgeschlagener IMAP-Sync oder E-Mail-Versand still – der User
sah nur im Browser-Devtools, dass etwas schief lief. Jetzt erscheint eine rote
Toast-Benachrichtigung (8 Sekunden) mit der konkreten Fehlermeldung des Servers,
z.B. 'Sync fehlgeschlagen: IMAP-Authentifizierung fehlgeschlagen: NO [AUTHENTICATIONFAILED]'.

EmailClientTab (Synchronisieren-Button):
- toast.success bei erfolgreichem Sync
- toast.error bei Fehler + bei Backend-Response mit success=false

ComposeEmailModal (Senden):
- toast.success bei erfolgreichem Versand
- toast.error bei SMTP-Fehler mit Server-Response (zusätzlich zum Inline-Fehler)

Außerdem im imapService.testImapConnection:
- Roh-Error wird jetzt geloggt (code, response, responseStatus, authenticationFailed)
- ImapFlow-spezifische Felder werden in die Fehlermeldung übernommen, sodass
  z.B. '2 NO [AUTHENTICATIONFAILED] Authentication failed.' direkt sichtbar wird

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:16:04 +02:00
duffyduck 1de8fb9847 Fix: IMAP/SMTP mit älteren TLS-Versionen zulassen
Der Fehler 'Client network socket disconnected before secure TLS connection
was established' tritt auf, wenn der Mailserver nur alte TLS-Versionen (1.0/1.1)
oder legacy Cipher-Suites anbietet - Node.js 20+ schließt dann den Socket, noch
bevor überhaupt ein Zertifikat gesehen wird. Das Häkchen 'Selbstsignierte
Zertifikate erlauben' greift zu spät, weil der Handshake gar nicht startet.

Fix: Wenn 'Selbstsignierte Zertifikate erlauben' aktiv ist, setzen wir gleich
auch minVersion='TLSv1' und ciphers='DEFAULT:@SECLEVEL=0'. Damit akzeptiert
Node.js auch alte Cipher-Suites und TLS-Versionen des Mailservers.

Bei aktivem 'allowSelfSignedCerts' heißt das zusammen:
- rejectUnauthorized: false (Zertifikate akzeptieren auch wenn selbstsigniert)
- minVersion: 'TLSv1' (auch alte TLS-Versionen zulassen)
- ciphers: 'DEFAULT:@SECLEVEL=0' (auch schwache Ciphers zulassen)

Refactor:
- imapService: neuer Helper buildTlsOptions() – ersetzt 8 identische
  Inline-Setups, damit die Fix-Logik zentral gepflegt wird
- smtpService: tls-Type erweitert (minVersion/ciphers), gleiche Logik

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:05:04 +02:00
duffyduck fd55f3129f E-Mail-Zugang Test (IMAP + SMTP) in Provider-Einstellungen
Das bestehende „Verbindung testen" prüft nur den API-Zugang (Plesk/cPanel),
nicht den eigentlichen IMAP/SMTP-Zugang der System-E-Mail. Das führte dazu,
dass Anhang-Downloads scheiterten obwohl der API-Test grün war.

Neuer Button im EmailProviders-Modal: „E-Mail-Zugang testen (IMAP + SMTP)"
- Testet IMAP-Empfang und SMTP-Versand separat
- Zeigt pro Protokoll Erfolg oder Fehlermeldung mit Server/Port/Verschlüsselung
- Nutzt die hinterlegte System-E-Mail-Adresse + Passwort
- Funktioniert auch vor dem ersten Speichern (mit Formulardaten)

Außerdem im Anhang-Download:
- Retry-Mechanismus bei transienten TLS/Netzwerk-Fehlern (3 Versuche)
- Socket-Timeout 30s gegen hängende Verbindungen
- Sprechende Fehlermeldungen (z.B. Hinweis auf selbstsigniertes Zertifikat)
- Debug-Logging mit Host/Port/User/Folder/UID

Backend:
- Neuer Endpoint POST /api/email-providers/test-mail-access
- fetchAttachment in imapService: Retry-Wrapper + fetchAttachmentInner
- Besseres Error-Handling in downloadAttachment (Cert-Hinweis, Auth, Timeout)

Frontend:
- emailProviderApi.testMailAccess()
- EmailProviders-Modal: neuer Button + zweispaltige Ergebnis-Anzeige für IMAP+SMTP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:59:06 +02:00
duffyduck 109f774d62 docs: Factory-Defaults Import/Export-Anleitung in READMEs
Haupt-README.md: neuer Abschnitt mit Abgrenzung zu Datenbank-Backup, Schritt-
für-Schritt-Anleitung für Export und Import, Idempotenz-Hinweis, Berechtigungen.

backend/factory-defaults/README.md: ausführliche Referenz mit Struktur-Beispielen
aller JSON-Dateien (Provider, CancellationPeriod, ContractDuration,
ContractCategory, PdfTemplate), Teil-Import-Anleitung, Merge-Beispiele.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:19:02 +02:00
duffyduck 60dc98e265 Factory-Defaults: Export + Import von Stammdaten-Katalogen
Ein neues System um Stammdaten-Kataloge zwischen Installationen zu teilen –
explizit ohne Kundendaten, Verträge oder Einstellungen.

**Was wird exportiert:**
- Anbieter + zugehörige Tarife
- Kündigungsfristen
- Vertragslaufzeiten
- Vertragskategorien
- PDF-Auftragsvorlagen (JSON + PDF-Dateien + Feldzuordnungen)

**Was NICHT:**
- Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen
  → dafür gibt es den Datenbank-Backup

**Neue Einstellungsseite /settings/factory-defaults:**
- Zeigt Anzahl pro Kategorie (Anbieter, Tarife, Fristen, …)
- "Exportieren"-Button lädt ZIP herunter (manifest.json + JSONs + PDFs)
- Import-Anleitung inline

**Import-Script:**
- `npm run seed:defaults` (tsx scripts/seed-factory-defaults.ts)
- Liest alle JSON-Dateien aus backend/factory-defaults/*/*.json
- Merged mehrere Dateien automatisch pro Kategorie (unique-key gewinnt zuletzt)
- Upsertet idempotent → kann mehrfach ausgeführt werden
- Kopiert PDF-Vorlagen aus factory-defaults/pdf-templates/ nach uploads/pdf-templates/
- Alte PDF-Dateien werden beim Re-Import entsorgt

Backend:
- services/factoryDefaults.service.ts: collectFactoryDefaults() + exportFactoryDefaults()
- controllers/factoryDefaults.controller.ts: preview + export
- routes/factoryDefaults.routes.ts: GET /api/factory-defaults/preview + /export
- scripts/seed-factory-defaults.ts: CLI-Import-Script
- .gitignore: factory-defaults/* außer .gitkeep und README.md

Frontend:
- pages/settings/FactoryDefaults.tsx: Übersicht + Export-Button
- Settings-Karte „Factory-Defaults" im System-Abschnitt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:10:12 +02:00
duffyduck b78afce43c Fix: Anrede per Du/Sie wird nicht gespeichert
Das useInformalAddress-Feld war:
1. Im Frontend-Submit-Handler nicht in submitData enthalten (wurde bei jedem Update rausgefiltert)
2. Im Service-Type nicht definiert (TypeScript-mäßig unbekannt)
3. Beim Laden im Edit-Mode: Boolean aus DB matchte nicht das String-value des <select>

Fixes:
- Frontend: submitData enthält jetzt useInformalAddress (String oder Boolean → sauberes Boolean)
- Frontend: beim reset() wird Boolean zu 'true'/'false' konvertiert für <select>
- Backend: Service-Type erweitert um useInformalAddress, autoBirthdayGreeting, autoBirthdayChannel
- Backend: Audit-Feldlabels für die neuen Felder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:10:03 +02:00
duffyduck 2879bd64d6 Email-Anhänge als Vertragsdokumente + Rechnungen für alle Vertragstypen
Der SaveAttachmentModal hat jetzt drei Modi (wenn E-Mail einem Vertrag zugeordnet ist):

1. Als Dokument – in feste Slots (Kündigungsschreiben etc.), unverändert
2. Als Vertragsdokument – NEU: flexible ContractDocument-Tabelle mit Typ-Dropdown
   (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht,
   Widerrufsbelehrung, Preisblatt, Sonstiges) + optionalen Notizen
3. Als Rechnung – jetzt für ALLE Vertragstypen (vorher nur Strom/Gas)

Backend:
- Neuer Endpoint POST /api/emails/:id/attachments/:filename/save-as-contract-document
- saveAttachmentAsInvoice + saveEmailAsInvoice: ELECTRICITY/GAS-Einschränkung entfernt,
  nutzt jetzt addInvoiceByContract als Fallback für Nicht-Energie-Verträge

Frontend:
- cachedEmailApi.saveAttachmentAsContractDocument hinzugefügt
- SaveAttachmentModal: neuer Mode 'contractDocument' mit Typ+Notizen
- Mode-Toggle zeigt jetzt alle drei Optionen wenn Vertrag zugeordnet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 13:06:10 +02:00
duffyduck aa2b5ce785 Fix: Emoji im Plain-Text für Messenger entfernt
Das Sternchen-Emoji 🌟 im Geburtstagsgruß wurde in WhatsApp Web als Fragezeichen angezeigt
(URL-Encoding-Problem). Für Messenger-Kanäle bleibt der Text jetzt emoji-frei, per E-Mail
werden die Emojis weiterhin korrekt dargestellt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:55:06 +02:00
duffyduck 9d6bd68ddc Geburtstag-Management-Modal mit Reset + Send + Auto-Flag
Neuer Cake-Button neben dem Geburtsdatum in den Stammdaten öffnet ein Modal
mit drei Funktionen:

1. **Gruß-Marker zurücksetzen** (lastBirthdayGreetingYear → null)
   - Für Debugging oder als Fallback, wenn der Kunde den Gruß erneut sehen soll
   - Mit Bestätigungsdialog

2. **Geburtstagsgruß jetzt senden** (Email / WhatsApp / Telegram / Signal)
   - Email: direkt via System-SMTP mit HTML-Template (Du/Sie-abhängig)
   - WhatsApp/Telegram/Signal: öffnet vorbefülltes Fenster mit Gruß-Text
   - Text beachtet Du/Sie-Verhältnis (pronomen, possessiv, etc.)
   - Mit Bestätigungsdialog

3. **Automatisch senden** – neue Einstellung am Customer
   - autoBirthdayGreeting (Boolean) + autoBirthdayChannel (String)
   - Für späteren Cron-basierten Automatik-Versand vorbereitet

Backend:
- birthday.service.ts: resetBirthdayGreeting, buildBirthdayGreetingText, getBirthdayGreetingData
- birthday.controller.ts: resetBirthdayGreeting, sendBirthdayGreeting
- Routes: POST /birthdays/:customerId/reset + /send
- Audit-Log bei beiden Aktionen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:46:03 +02:00
duffyduck 2a3928d0e7 Anrede-Verhältnis Du/Sie pro Kunde + Geburtstagsgruß respektiert Anrede
Schema:
- Customer.useInformalAddress: Boolean (Default: false = Sie)
- Auch bei Firmenkunden verfügbar (Chef kann man auch duzen)

Frontend:
- Neues Pflichtfeld "Anrede per" (Du/Sie) im Kunden-Formular
- Anzeige als Badge in CustomerDetail-Stammdaten

Geburtstagsgruß im Portal:
- Bei Du: "Herzlichen Glückwunsch, Max! Alles Gute zu deinem 42. Geburtstag!"
- Bei Sie: "Herzlichen Glückwunsch, Herr Müller! Alles Gute zu Ihrem 42. Geburtstag!"
- Konsistent auch bei nachträglichen Glückwünschen (hattest/hatten, bist/sind etc.)
- Backend liefert firstName, lastName, salutation und useInformalAddress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:27:23 +02:00
duffyduck ba29711ee7 Geburtsdatum + Geburtsort auch bei Firmenkunden anzeigen/bearbeiten
Bisher wurden die Felder nur bei Privatkunden angezeigt. Jetzt sind sie
unabhängig vom Kundentyp verfügbar, z.B. für Ansprechpartner bei Firmen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:05:44 +02:00
duffyduck 888c75bb41 todo: Factory-Defaults Abgrenzung zu Backup klarstellen
Explizit aufgenommen: KEINE Kundendaten, Dokumente, Emails oder Einstellungen.
Nur reine Stammdaten-Kataloge. Für vollständige Backups gibts den separaten Export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:56 +02:00
duffyduck 5adc71e52c todo: Factory-Defaults Export/Import als neuer Punkt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:59:24 +02:00
duffyduck c93086059d chore: Build-Artefakte und node_modules aus Tracking entfernen
- backend/dist, backend/node_modules aus Git-Tracking entfernt (waren bereits in .gitignore)
- frontend/dist, frontend/node_modules ebenfalls entfernt
- Neue frontend/.gitignore erstellt (fehlte komplett)

Die Dateien bleiben lokal erhalten und werden durch npm install + build regeneriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:09 +02:00
duffyduck b47f33aaa5 todo: Geburtstagskalender als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:48 +02:00
duffyduck 018784cca6 Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal
Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:20 +02:00
duffyduck 2775e9d4dc todo: Vertragslisten-Erweiterung als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:20:01 +02:00
duffyduck 0d58b79836 Typspezifische Zusatzinfos in Vertragslisten
Jede Vertragszeile zeigt jetzt eine kontextspezifische Zusatzinfo an:
- Strom/Gas: "Lieferadresse: Musterstr. 12, 12345 Berlin"
- DSL/Glasfaser/Kabel: "Anschlussadresse: ..."
- Mobilfunk: "Rufnummer: 0171 1234567" (Hauptkarte bevorzugt)
- KFZ: "Kennzeichen: HB-AB 123"

Sichtbar in:
- Admin-Vertragsliste (/contracts)
- Portal-Vertragsliste (Baumansicht)
- Kunden-Detail → Verträge-Tab

Backend: getAllContracts + getContractTreeForCustomer liefern
mobileDetails (mit simCards), carInsuranceDetails und address mit.

Frontend: Neuer Helper utils/contractInfo.ts mit getContractTypeInfo,
aus dem sowohl Label als auch Wert pro Typ kommt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:04 +02:00
duffyduck eaf7d1eac3 update todo.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:18:33 +02:00
duffyduck 9a84e2d3cb PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung
- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00
duffyduck 9fa1cbc591 delete test pdf 2026-03-27 12:12:22 +01:00
duffyduck a0705b1a61 email datenschztz erst alles bestätigt bei allen hebeln 2026-03-27 12:03:20 +01:00
duffyduck 0e75e6c8e5 added place to telecommunication, added contract documents, added invoice to other contracts 2026-03-25 16:55:48 +01:00
duffyduck a15772cb54 impressum datenschutz added 2026-03-25 15:25:34 +01:00
duffyduck fd55742c57 complete new audit system 2026-03-21 18:23:54 +01:00
duffyduck 38b3b7da73 Datenschutz vollmacht fixed, two time counter added 2026-03-21 16:42:31 +01:00
duffyduck eecc6cd73e fixed back button with source, and customer in customer lsit clickable 2026-03-21 12:16:04 +01:00
duffyduck d7b42f64b1 fixed all back buttons 2026-03-21 12:03:32 +01:00
duffyduck c3edb8ad2e gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery 2026-03-21 11:59:53 +01:00
duffyduck 09e87c951b added docker setup 2026-02-08 19:59:49 +01:00
duffyduck 468907c9c3 added recovery entires, changed recovery icon 2026-02-08 19:43:46 +01:00
duffyduck e0fc26795e added contract history 2026-02-08 19:24:37 +01:00
duffyduck 746706ef01 added date at support ticket, new order support tickets, delete edit support ticktes only from enploye and admins 2026-02-08 18:26:34 +01:00
duffyduck 4f588015a4 contractnumber provider added, old provider number field only if no previous contact exist 2026-02-08 14:34:56 +01:00
duffyduck 55f257fffd readme updated 2026-02-08 13:14:24 +01:00
duffyduck 2ab2bb7562 snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug 2026-02-08 13:08:58 +01:00
duffyduck 839bb40f5e fixed issue stressfrei adress as username not filled oin cockpit 2026-02-08 09:09:00 +01:00
duffyduck b397a974df updated readme.md 2026-02-08 01:21:00 +01:00
duffyduck ad2b8ea5b6 added invoices and status in cockpit, created info button for contract status types 2026-02-08 01:18:12 +01:00
duffyduck 89d528bb77 addes cost and usage calculation 2026-02-06 00:14:38 +01:00
duffyduck e8919cfa81 added extra field kwh at m3, expand cost field to 10 komma, added maloid,counter add dialog, auto set unit 2026-02-05 20:34:45 +01:00
duffyduck 80dd5cc157 contractmodaldetail date format and before contract and next contract question to add 2026-02-04 21:17:13 +01:00
duffyduck f33d157b9b save email as pdf likae attachment version 2 2026-02-04 19:49:09 +01:00
duffyduck 8c65fecef0 remove uploads from repo but keep empty folder 2026-02-04 19:19:18 +01:00
duffyduck 866a285037 save email as pdf like an attachment 2026-02-04 19:18:32 +01:00
duffyduck c9d1ad7796 added tree view to customer portal, in employe its uses still list 2026-02-04 16:30:49 +01:00
duffyduck f21eb20715 seperate delivery and billig adresses in contract added 2026-02-04 08:48:25 +01:00
duffyduck 7a0f4461aa fixed, bankcard, adresses, id card, tarif name dropdown menu in edit mode 2026-02-04 08:37:46 +01:00
duffyduck d8ced5cb24 added new view in contracts customer and contracts 2026-02-04 00:52:04 +01:00
duffyduck b87053760e save attachment from email in customer data and - or contracts 2026-02-03 23:58:00 +01:00
duffyduck 30103e6099 update readme.md 2026-02-03 23:08:08 +01:00
duffyduck bf068276b5 all email views the same 2026-02-03 23:04:42 +01:00
duffyduck 9256d9397b optimize password view in stressfrei addresses 2026-02-03 15:43:00 +01:00
duffyduck 8c9e61cf17 added backup and email client 2026-02-01 00:02:35 +01:00
duffyduck ef18381dd8 imapclient feature plan 2026-01-29 01:34:43 +01:00
Stefan Hacker e209e9bbca first commit 2026-01-29 01:16:54 +01:00
177 changed files with 18368 additions and 2283 deletions
+9
View File
@@ -46,6 +46,15 @@ backups
backend/uploads
backend/backups
# Daten-Verzeichnis (Bind-Mounts zur Laufzeit, nicht im Build-Context)
data/
# Plesk-Test (nicht für Container)
plesktest/
# Backup-Klone des Repos
opencrm-backup-*/
# Prisma migrations (included, but not dev db)
*.db
*.db-journal
+102
View File
@@ -0,0 +1,102 @@
# OpenCRM zentrale Konfiguration
# ==================================
# Kopiere diese Datei zu .env und passe die Werte an.
# Diese .env wird sowohl vom Backend (npm run dev) als auch von Docker
# Compose verwendet.
# ============== PORTS (extern erreichbar auf dem Host) ==============
OPENCRM_PORT=3010 # Backend + Frontend (alles unter einer URL)
ADMINER_PORT=8090 # Adminer (Datenbank-UI). 8081 ist häufig schon belegt.
DB_PORT=3306 # MariaDB extern (für lokale Tools/Dev). 0 = nicht freigeben.
# ============== DATEN-PFADE (Bind-Mounts) ==============
# Relativ zum Projektverzeichnis. Werden zur Laufzeit angelegt.
DATA_DIR=./data
DB_DATA_DIR=./data/db
UPLOADS_DIR=./data/uploads
FACTORY_DEFAULTS_DIR=./data/factory-defaults
BACKUPS_DIR=./data/backups
# ============== DATENBANK ==============
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
# angelegt (über MARIADB_USER/MARIADB_PASSWORD im docker-compose) mit
# GRANT ALL PRIVILEGES auf ${DB_NAME}.*. Damit nutzt das Backend NICHT root.
# DB_ROOT_PASSWORD ist nur für Adminer / Notfall-Wartung.
DB_HOST=localhost # Im Container überschreibt docker-compose das auf "db"
DB_NAME=opencrm
DB_USER=opencrm
DB_PASSWORD=change-this-password
DB_ROOT_PASSWORD=change-this-root-password
# Connection-String wird aus den DB_*-Komponenten zusammengebaut (dotenv-expand).
# Manuell überschreiben nur wenn Sonderfälle (z.B. extra Query-Parameter).
# Hinweis: für lokales Dev mit MariaDB im Container nutze DB_HOST=localhost,
# weil docker-compose den DB-Port auf 127.0.0.1:DB_PORT mappt.
DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
# ============== SECURITY ==============
# JWT-Secret: min. 32 Zeichen. Generieren: openssl rand -hex 64
# Wird sowohl für Access- als auch Refresh-Token verwendet.
JWT_SECRET=change-this-to-a-very-long-random-secret-please-rotate-before-production
# Access-/Refresh-Token-Lifetimes
# - Access-Token: kurzlebig, lebt nur im Browser-Memory (XSS-Schutz)
# - Refresh-Token: lang, im httpOnly-Cookie (JS-unzugänglich)
# Wenn der Access abläuft, holt das Frontend transparent einen neuen über
# /api/auth/refresh User merkt nichts. Logout invalidiert beide sofort.
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Encryption-Key für Portal-Credentials: GENAU 64 Hex-Zeichen.
# Generieren: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
# Server
NODE_ENV=development
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
LISTEN_ADDR=0.0.0.0 # In Docker = 0.0.0.0, in Bare-Metal-Production = 127.0.0.1
# CORS nur in Production setzen, wenn Frontend auf separater Domain läuft.
# Beispiel: CORS_ORIGINS=https://crm.deine-domain.de
# CORS_ORIGINS=
# HTTPS-only-Header (HSTS + upgrade-insecure-requests) NUR aktivieren, wenn
# wirklich ein TLS-Proxy (Caddy/Traefik/Nginx) vor OpenCRM steht. Sonst sperrt
# sich der Browser bei direktem http://ip:port-Zugriff selbst aus
# (ERR_SSL_PROTOCOL_ERROR auf den Assets).
HTTPS_ENABLED=false
# SSRF-Schutz: private IP-Ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost) bei Provider/SMTP-Test-Connection
# blockieren. Default `false` damit On-Prem-Setups Plesk/Dovecot/Postfix auf
# 127.0.0.1 oder im internen Netz nutzen können. Für Cloud-Deployments
# (öffentlich erreichbares Backend) auf `true` setzen, sonst kann ein
# eingeloggter Admin via /email-providers/test-connection interne Services
# anpingen. Cloud-Metadata-Endpoints (169.254.169.254 etc.) sind UNABHÄNGIG
# vom Flag immer geblockt.
SSRF_BLOCK_PRIVATE_IPS=false
# ============== ADMINER (DB-UI) ==============
# Theme-Auswahl. Verfügbare Designs im offiziellen adminer:latest Image:
# adminer-dark, brade, bueltge, dracula, esterka, flat, galkaev,
# haeckel, hever, konya, lavender-light, lucas-sandery, mancave,
# mvt, nette, ng9, nicu, pappu687, paranoiq, pepa-linha, pokorny,
# price, rmsoft, rmsoft_blue, rmsoft_blue-dark, win98
# Empfehlung: dracula (dark) oder adminer-dark beide modern.
ADMINER_DESIGN=dracula
# ============== SEED ==============
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com +
# Stammdaten an) nichts zu konfigurieren.
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
# wieder zurück.
RUN_SEED=false
# Initial-Passwort für admin@admin.com beim Seed. Mindestens 25 Zeichen,
# sonst wird der Wert ignoriert und stattdessen ein 28-Zeichen-Zufalls-
# passwort erzeugt und EINMAL nach stdout geschrieben:
# docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
# Wer keine Lust auf die Log-Suche hat, setzt hier ein eigenes starkes
# Passwort vor dem ersten `docker compose up -d`.
# SEED_ADMIN_PASSWORD=
+41
View File
@@ -0,0 +1,41 @@
# Root-Gitignore: gemeinsame Patterns für Repo-Root + nested Verzeichnisse
# (backend/, frontend/, docker/ haben zusätzlich eigene .gitignore-Files)
# Environment echte Secrets blocken, .env.example weiter mittracken
.env
.env.local
.env.*.local
!.env.example
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Temp
tmp/
*.tmp
*.bak
# Docker-Bind-Mounts: Inhalt nicht tracken, Verzeichnisstruktur via .gitkeep behalten
data/db/*
!data/db/.gitkeep
data/uploads/*
!data/uploads/.gitkeep
data/factory-defaults/*
!data/factory-defaults/.gitkeep
data/backups/*
!data/backups/.gitkeep
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
factory-exports/*
!factory-exports/.gitkeep
+615 -18
View File
@@ -2,6 +2,8 @@
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
**Version: 1.1.0** ([Changelog](#changelog))
## Features
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
@@ -11,6 +13,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT``ACTIVE` (mit Vertragsbeginn),
Kündigungsbestätigung-Upload setzt `ACTIVE``CANCELLED` (mit Datum),
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
- **Verträge**:
- Energie (Strom, Gas)
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
@@ -20,7 +25,14 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
Sofort-E-Mail-Alerts und stündlichem Digest siehe Einstellungen → Monitoring
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
## Tech Stack
@@ -29,38 +41,83 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Backend**: Node.js, Express 4.x, TypeScript
- **Datenbank**: MariaDB
- **ORM**: Prisma
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle
- **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
> - Express 4.x → `@types/express@^4.17.x`
> - Express 5.x → `@types/express@^5.x` (erst bei offiziellem Release empfohlen)
## Quick-Start mit Docker (empfohlen)
Komplettes Setup mit MariaDB + OpenCRM + Adminer (DB-UI) in 3 Befehlen:
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Werte anpassen, Secrets rotieren!
docker compose up -d
```
Browser:
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` Initial-Passwort siehe unten)
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
Alle persistenten Daten liegen in `./data/`:
| Pfad | Inhalt |
|------|--------|
| `./data/db/` | MariaDB-Datafiles |
| `./data/uploads/` | User-Uploads (PDFs, Bilder) |
| `./data/factory-defaults/` | Stammdaten-Kataloge |
| `./data/backups/` | DB-Backups (`npm run db:backup`) |
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
> **Erste Inbetriebnahme:** Bei leerer DB läuft der Seed automatisch
> (kein Eingriff nötig). Nur wenn du eine bereits befüllte DB erneut
> seeden willst, einmalig `RUN_SEED=true` in der `.env` setzen,
> `docker compose up -d` ausführen, dann wieder auf `false`.
>
> Der initiale Admin-User ist **`admin@admin.com`**. Das Passwort wird
> beim Seed zufällig generiert (28 Zeichen) und genau einmal in den
> Container-Logs ausgegeben:
> ```bash
> docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
> ```
> Alternativ kannst du **vor dem ersten Start** ein eigenes Passwort
> setzen, indem du `SEED_ADMIN_PASSWORD=…` (mindestens 25 Zeichen, sonst
> wird's ignoriert) in der `.env` einträgst. Nach dem ersten Login
> sollte das Passwort über *Einstellungen → Mein Profil* geändert
> werden.
## Voraussetzungen
- Node.js 18+ (empfohlen: 20+)
- Docker & Docker Compose
- npm
- Docker & Docker Compose v2
- Für Backend-Entwicklung außerhalb von Docker: Node.js 20+ und npm
## Installation
## Installation für Entwicklung (ohne Container)
### 1. Repository klonen
```bash
git clone <repository-url>
cd opencrm
cp .env.example .env # Konfiguration anpassen
```
### 2. MariaDB-Datenbank starten
### 2. MariaDB-Container starten
```bash
docker-compose up -d
docker compose up -d db
```
Dies startet einen MariaDB-Container mit:
- **Port:** 3306
- **Datenbank:** opencrm
- **Root-Passwort:** rootpassword
- **Benutzer:** opencrm / opencrm123
Das startet nur die Datenbank (mit Daten in `./data/db/`).
Konfiguration kommt aus `./.env`:
- **Port:** wie in `DB_PORT` (Standard: 3306, intern auf 127.0.0.1)
- **Datenbank/User/Passwort:** wie in `DB_*`-Variablen
Warte ca. 10 Sekunden bis die Datenbank bereit ist.
@@ -82,9 +139,14 @@ Die `.env`-Datei sollte folgende Werte enthalten:
# Database
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT
# JWT Access-/Refresh-Token-Pattern (SPA-Standard)
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
# Refresh-Token (httpOnly-Cookie, lang)
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d"
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -137,8 +199,223 @@ Das Frontend läuft auf `http://localhost:5173`
Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** admin@admin.com
- **Passwort:** admin
- **E-Mail:** `admin@admin.com`
- **Passwort:** wird beim Seed zufällig generiert (28 Zeichen) und einmal
in den Container-Logs ausgegeben:
```bash
docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
```
Beispiel-Ausgabe:
```
========================================================
Admin-User: admin@admin.com
Initial-Passwort: <28-zeichen-zufalls-passwort>
⚠️ Dieses Passwort wird hier EINMAL ausgegeben!
Bitte sofort nach dem ersten Login ändern.
========================================================
```
Wer das Log nicht mehr findet (z.B. Container schon länger gerollt),
setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` in der `.env` und startet
einmalig mit `RUN_SEED=true` neu der Seed ist idempotent und
überschreibt das Admin-Passwort. Danach `RUN_SEED` wieder auf `false`.
> **Tipp:** Wer von Anfang an ein eigenes Admin-Passwort haben möchte,
> setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` **bevor** der erste
> `docker compose up -d` läuft. Der Wert wird dann statt des Zufalls-
> passworts verwendet (siehe `.env.example`).
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren
> siehe [Production-Deployment](#production-deployment).
## Production-Deployment
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
```env
NODE_ENV=production
# Pflicht-Rotation per `openssl rand` neu generieren!
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
```
### Deployment-Modus: On-Prem vs. Cloud
OpenCRM ist primär als **On-Prem-Anwendung** designed (eigener Server / VM,
hinter Reverse-Proxy). Für **Cloud-Deployments** (öffentlich erreichbares
Backend, Shared-Infrastructure, Hyperscaler) gibt es einen zusätzlichen
SSRF-Schalter:
```env
# Cloud-Deploy: zusätzlich alle privaten IP-Ranges für Provider-/SMTP-
# Test-Connection blockieren (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
# 192.168.0.0/16, ::1, fc00::/7, localhost). Default false, weil
# On-Prem-Setups oft Plesk/Dovecot auf 127.0.0.1 brauchen.
SSRF_BLOCK_PRIVATE_IPS=true
```
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` wenn Frontend
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
auf derselben Domain via Proxy-Path.
- **Admin-Initial-Passwort ändern**: das beim Seed generierte Zufalls-Passwort
(siehe [Erster Login](#erster-login)) im Profil überschreiben oder gleich
per `SEED_ADMIN_PASSWORD` in der `.env` vor dem ersten Start setzen.
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
hinterlegen, Test-Alert senden, Digest aktivieren.
- Vollständige Hardening-Story + restliche Trade-offs:
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
### ⚠️ Wichtig: gzip für `/api/*` am Reverse-Proxy deaktivieren (BREACH-Schutz)
Wenn ein TLS-Reverse-Proxy (Nginx Proxy Manager, Caddy, eigener Nginx, …) HTTPS
terminiert und Antworten gzip-komprimiert, ist die **BREACH-Attacke** (CVE-2013-3587)
theoretisch möglich: aus der gzip-komprimierten Response-Größe könnten unter
ungünstigen Umständen Secrets erraten werden. Auch wenn unsere JWT-basierte SPA
das Risiko praktisch klein hält (keine reflektierten Secrets im Response-Body),
geht ein Penetration-Test mit testssl trotzdem auf „medium Ausnutzbar: Ja".
**Lösung:** gzip-Komprimierung nur für statische Frontend-Assets erlauben, für
`/api/*` deaktivieren. Statische Bundles bleiben damit performant ausgeliefert,
JSON-API-Responses werden ohne Kompression gesendet → BREACH ist dort kein
Einfallstor mehr.
**Nginx Proxy Manager (NPM):**
1. Proxy-Hosts → den CRM-Host → **Edit**
2. Tab **Custom Locations** → **„Add location"**
3. **Define location:** `/api/`
4. **Scheme:** `http`, **Forward Hostname/IP:** wie im Haupt-Host
(z.B. `172.0.2.39`), **Forward Port:** `3010`
5. Zahnrad rechts an der Location → erweiterte Config eintragen:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
6. **Save** (Location), **Save** (Proxy-Host)
> Der `more_clear_headers`-Befehl kommt aus dem `headers-more`-Modul, das
> bei NPM standardmäßig dabei ist. Damit verschwinden die Banner
> `Server: openresty` und `x-served-by: …` aus den Responses Pentest-
> Tools können den eingesetzten Webserver nicht mehr direkt aus dem Header
> ablesen. Wer das auch auf der Hauptlocation will, kann denselben Eintrag
> zusätzlich im **Advanced**-Tab des Proxy-Hosts setzen.
**Plain Nginx** (falls eigener Nginx statt NPM):
```nginx
location /api/ {
gzip off;
proxy_pass http://backend:3010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By; # braucht headers-more-Modul
}
# Optional global im server { … }-Block:
server_tokens off;
```
**Verifikation:**
```bash
# 1) gzip ist für /api/ deaktiviert (sollte leer sein)
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/api/health \
| grep -i content-encoding
# 2) Server-/x-served-by-Banner sind weg (sollte leer sein)
curl -sI https://kundencenter.deine-domain.de/api/health \
| grep -iE '^(server|x-served-by):'
```
#### Was mit gzip auf `/` (SPA-HTML) ist
Pentest-Tools wie `testssl` melden BREACH **trotzdem weiter** für die
Root-URL `/`, weil die SPA-`index.html` bewusst weiter gzip-komprimiert
ausgeliefert wird (Performance: 50 KB → ~10 KB). Bei OpenCRM ist der
Angriff dort nicht ausnutzbar:
- Die `/`-Response ist die statische `index.html` aus dem Vite-Build
- Sie reflektiert **keinen user-controlled Input**
- Sie enthält **keine Secrets** (JWT-Access ist im `Authorization`-Header,
Refresh-Token im httpOnly-Cookie beides nicht im HTML-Body)
Ohne Secret-im-Body und ohne Input-Reflektion hat BREACH keinen Hebel.
##### Wer den Audit-Marker trotzdem weg haben will
Wichtig: nicht einfach eine Custom-Location für `/` mit `gzip off`
anlegen das wäre ein **prefix-Match** und würde **alle** Pfade
außer `/api/*` betreffen, also auch `/assets/*.{js,css}`. Das JS-Bundle
käme dann unkomprimiert (~500 KB statt ~150 KB) → spürbarer
Performance-Verlust für nichts.
Sauber ist eine **exact-Match-Location** (`location = /`) die fängt
nur die Root-URL ohne weitere Pfad-Komponente:
**Variante A** Custom Location im NPM-UI (falls `= /` im
„Define location"-Feld akzeptiert wird):
| Feld | Wert |
|---|---|
| Define location | `= /` |
| Scheme | `http` |
| Forward Hostname/IP | wie im Haupt-Host |
| Forward Port | `3010` |
Im Zahnrad-Edit der Location:
```nginx
gzip off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Information-Disclosure-Header weg (Pentest-Hygiene):
more_clear_headers Server X-Served-By;
```
**Variante B** wenn das NPM-UI das `=` nicht akzeptiert, dieselbe
Logik im **Advanced**-Tab des Proxy-Hosts:
```nginx
location = / {
gzip off;
proxy_pass $forward_scheme://$server:$port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
more_clear_headers Server X-Served-By;
}
```
Verifikation `/` ohne gzip, `/assets/*` aber weiter mit:
```bash
# Root: kein Content-Encoding mehr
curl -sI -H 'Accept-Encoding: gzip' https://kundencenter.deine-domain.de/ \
| grep -i content-encoding
# /assets/<file>.js: weiterhin gzip (Performance bleibt erhalten)
JS=$(curl -s https://kundencenter.deine-domain.de/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1)
curl -sI -H 'Accept-Encoding: gzip' "https://kundencenter.deine-domain.de/$JS" \
| grep -i content-encoding
```
Kostet 40 KB extra pro Tab-Reload aber dafür ist auch der letzte
BREACH-Marker weg und Pentest-Reports landen auf 0×MEDIUM.
## Developer-Tools aktivieren
@@ -170,12 +447,84 @@ Das System unterstützt die automatische Erstellung von E-Mail-Weiterleitungen a
- **Name**: Bezeichnung (z.B. "Plesk Hauptserver")
- **Typ**: Plesk/cPanel/DirectAdmin
- **API-URL**: Server-URL (z.B. `https://server.de:8443`)
- **Benutzername/Passwort**: API-Zugangsdaten
- **API-Key** _(empfohlen bei Plesk)_: Key aus Plesk (siehe unten), alternativ Benutzername/Passwort
- **Benutzername/Passwort**: Nur wenn kein API-Key vorhanden
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
3. Provider als "Standard" und "Aktiv" markieren
4. Verbindung testen
### Plesk: API-Key anlegen
Der API-Key ist die empfohlene Authentifizierungsmethode (sicherer als Passwort, kann pro
Anwendung vergeben und widerrufen werden).
**Variante 1: Über die Plesk-Oberfläche (einfachster Weg)**
1. In Plesk als Admin einloggen
2. Oben rechts auf den **eigenen Namen** → **"Mein Profil"** (oder direkt URL `/admin/my-profile/`)
3. Tab **"API-Token"** oder **"API-Schlüssel"** öffnen
4. **"API-Schlüssel erstellen"** (bzw. "Add API Key")
5. Beschreibung vergeben (z.B. "OpenCRM")
6. Den angezeigten Schlüssel **sofort kopieren** er wird nur einmal angezeigt!
7. Im CRM bei "API-Key" einfügen
> **Hinweis:** Bei manchen Plesk-Versionen ist die Option unter
> **Tools & Einstellungen** → **API-Schlüssel** oder **Werkzeuge & Einstellungen** →
> **API-Tokens** zu finden. Wenn der Menüpunkt fehlt, muss ggf. die **REST API**
> Extension installiert werden (siehe Variante 2).
**Variante 2: Über die Kommandozeile (SSH als root)**
Falls der API-Key-Button in Plesk nicht vorhanden ist, lässt er sich auch per SSH erstellen:
```bash
# API-Key generieren (läuft nicht ab)
# WICHTIG: -ip-address weglassen, wenn der Key von beliebigen IPs genutzt werden soll!
plesk bin secret_key --create -description "OpenCRM"
# Alternativ mit IP-Einschränkung (nur Zugriffe von dieser IP sind erlaubt):
plesk bin secret_key --create -ip-address <IP-DES-CRM-SERVERS> -description "OpenCRM"
```
> **Achtung:** `-ip-address 0.0.0.0` funktioniert **nicht** wie bei anderen Tools!
> Plesk prüft exakt gegen die eingetragene IP. Für "alle IPs erlauben" muss der
> `-ip-address`-Parameter komplett weggelassen werden.
Der Befehl gibt den Key direkt zurück. Diesen kopieren und im CRM eintragen.
**Alle API-Keys anzeigen:**
```bash
plesk bin secret_key --list
```
**API-Key löschen:**
```bash
plesk bin secret_key --delete <KEY>
```
### Plesk: REST API aktivieren (falls nicht vorhanden)
Bei älteren Plesk-Versionen oder Custom-Installationen kann es sein, dass die
REST API fehlt. Dann:
1. **Tools & Einstellungen** → **Updates** → **Erweiterungen hinzufügen/entfernen**
2. Nach **"REST API"** suchen und installieren
3. Plesk-Neustart (meist nicht nötig, aber zur Sicherheit)
### Plesk: Firewall-Hinweis
Der CRM-Server muss den **Plesk-Port 8443** (Standard) erreichen können. Bei Plesk-Firewall:
1. **Tools & Einstellungen** → **Firewall**
2. **"Plesk-Dienst Panel"** (Port 8443) für die IP des CRM-Servers erlauben
Bei reiner Linux-Firewall (ufw/firewalld):
```bash
# Beispiel ufw
ufw allow from <CRM-SERVER-IP> to any port 8443
```
### Verwendung
Beim Anlegen einer Stressfrei-Wechseln Adresse im Kundenbereich erscheint die Checkbox **"Beim E-Mail-Provider anlegen"**, wenn:
@@ -916,6 +1265,254 @@ Folgende Felder werden in Audit-Logs gefiltert:
- API-Response wird nicht blockiert
- Before/After-Werte über Prisma Middleware
## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Auftragsvorlagen, HTML-Standardtexte)
zwischen verschiedenen OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt**
zu Datenbank-Backups:
### Abgrenzung
| | Factory-Defaults | Datenbank-Backup |
|---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| HTML-Standardtexte: Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **Secrets, JWT, Encryption-Keys, User-Accounts** | ❌ | ✅ |
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
> **Kurz:** Factory-Defaults = generische Stammdaten + rechtliche Standardtexte,
> Backup = die komplette Instanz.
### Drei Wege, eine ZIP zu transportieren
Es gibt drei Pfade, je nachdem wo die ZIP gerade liegen soll:
| Wo | Pfad | Wann |
|---|---|---|
| **Laufende DB einer Instanz** | UI-Upload oder `./factory-import.sh` | Bestehende Live-Instanz updaten |
| **Drop-Box im Repo** (`factory-exports/`) | `./factory-export.sh` legt ab, `./factory-import.sh` liest | Transfer zwischen dev und prod via `scp` |
| **Werkseinstellung im Image** (`backend/factory-defaults/`) | `./factory-import.sh --save-as-builtin` oder manuell entpacken | Neue VMs sollen die Defaults beim allerersten Start mitbringen |
Alle drei sind unabhängig, **alle drei zusammen** decken den typischen Workflow ab.
### Export
**Variante A UI:**
1. **Einstellungen** → **Factory-Defaults** öffnen
2. Button **„Factory-Defaults exportieren"** klicken
3. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
**Variante B CLI (für scp-Transfers):**
```bash
./factory-export.sh # → factory-exports/factory-defaults-…zip
OPENCRM_URL=https://crm.prod.example.de \
OPENCRM_EMAIL=admin@example.de ./factory-export.sh # gegen Prod-Instanz
```
Ohne `OPENCRM_PASSWORD` wird das Passwort interaktiv abgefragt. Der Zielordner
`factory-exports/` ist gitignored die ZIPs landen also nicht ins Repo.
**ZIP-Struktur:**
```
factory-defaults-2026-05-07-1949.zip
├── manifest.json # Version + Datum + Counts
├── providers/providers.json
├── contract-meta/
│ ├── cancellation-periods.json
│ ├── contract-durations.json
│ └── contract-categories.json
├── pdf-templates/
│ ├── pdf-templates.json
│ └── *.pdf # Die eigentlichen PDF-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates (Whitelist-only)
```
### Import
**Variante A UI:**
1. **Einstellungen** → **Factory-Defaults**
2. Bereich **Import** → **„ZIP hochladen"** → Datei wählen
3. Erfolgs-Box zeigt Counts pro Kategorie
**Variante B CLI:**
```bash
./factory-import.sh # nimmt jüngste ZIP aus factory-exports/
./factory-import.sh ./factory-exports/foo.zip # explizite ZIP
./factory-import.sh --save-as-builtin # zusätzlich ins Image-Default
./factory-import.sh --save-as-builtin ./foo.zip # entpacken (siehe unten)
```
Konfigurierbar per ENV: `OPENCRM_URL`, `OPENCRM_EMAIL`, `OPENCRM_PASSWORD`.
**Variante C Container-Bare-Metal (für Migration / mehrere ZIPs zusammenführen):**
```bash
# Inhalt der ZIP nach backend/factory-defaults/ entpacken (Unterordner beibehalten)
cd backend && npm run seed:defaults
```
**Beispiel-Output:**
```
✓ Anbieter: 10
✓ Tarife: 4
✓ Kündigungsfristen: 18
✓ Laufzeiten: 18
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 2
✓ HTML-Templates: 2
```
### `--save-as-builtin`: ZIP zur Werkseinstellung machen
Mit `--save-as-builtin` entpackt `factory-import.sh` die ZIP nach **erfolgreichem
DB-Import** zusätzlich in `backend/factory-defaults/`. Beim nächsten
`docker-compose up --build` landen die Defaults im Image. Frisch hochgezogene
VMs bringen sie dann beim ersten Start automatisch mit (Auto-Seed-Pfad im
Container-Entrypoint).
```bash
# typischer Sync prod → dev → Image-Default
ssh prod './factory-export.sh'
scp prod:opencrm/factory-exports/factory-defaults-*.zip factory-exports/
./factory-import.sh --save-as-builtin
docker-compose up -d --build # neuer Build, neue VMs starten direkt mit Defaults
```
Der Inhalt von `backend/factory-defaults/` wird beim `--save-as-builtin` vorher
geleert (außer `README.md` und `.gitkeep`), damit nichts Veraltetes liegen
bleibt.
### Mehrere ZIPs kombinieren (CLI-only, Variante C)
`backend/factory-defaults/` darf mehrere `*.json` pro Unterordner haben
`npm run seed:defaults` merged sie automatisch:
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus Verivox-Paket
check24.json # 30 Anbieter aus Check24-Paket
eigene.json # 5 eigene Anbieter
```
Bei gleichem Unique-Key gewinnt der zuletzt gelesene Eintrag. Der UI-/HTTP-Import
nimmt nur eine ZIP entgegen für Merges nutze `npm run seed:defaults`.
### Idempotenz
Alle Pfade nutzen Prisma `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (per unique Key: `name` / `code` / `key`) werden aktualisiert
- Nichts wird gelöscht
Du kannst Imports also beliebig oft hintereinander ausführen, ohne Datenverlust
oder Duplikate.
### PDF-Dateien
Beim Import werden PDF-Vorlagen aus dem ZIP nach `uploads/pdf-templates/`
kopiert (mit eindeutigem Suffix) und die `templatePath`-Spalte entsprechend
gesetzt. Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch
die neue ersetzt.
### AppSettings-Whitelist
Beim Import werden nur die Keys mit AppSetting-Schreibzugriff gewährt, die auch
exportiert werden aktuell:
- `privacyPolicyHtml`
- `imprintHtml`
- `authorizationTemplateHtml`
- `websitePrivacyPolicyHtml`
Andere Keys (SMTP, JWT, etc.) werden mit einer Warnung ignoriert. Whitelist ist
in [`backend/src/services/factoryDefaults.service.ts`](backend/src/services/factoryDefaults.service.ts)
zentral gepflegt.
### Auto-Seed beim Erst-Deploy
Bei einer **frischen** Installation (leere DB) spielt der Container-Entrypoint
nach dem Prisma-Seed automatisch das Built-in-Verzeichnis ein:
```
[entrypoint] DB ist leer (User-Count=0) Auto-Seed wird ausgeführt
[entrypoint] Spiele eingebaute Factory-Defaults ein…
✓ Anbieter: 10, Tarife: 4
```
Bei bestehenden Installs passiert das **nicht** nur frische DBs.
### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI/CLI) | `settings:update` |
| Factory-Defaults Import (UI/CLI) | `settings:update` |
| Werkseinstellungen ändern (`--save-as-builtin` / `npm run seed:defaults`) | Server-Zugang (SSH/Shell) |
### Typische Einsatzzwecke
- **Neue VM aufsetzen**: ZIP einmalig nach `backend/factory-defaults/` entpacken
(oder per `--save-as-builtin`), dann `docker-compose up --build` die
Werkseinstellungen sind beim ersten Start automatisch drin.
- **Prod-Stand zurück nach dev synchronisieren**: `./factory-export.sh` auf prod,
`scp` ins dev, `./factory-import.sh --save-as-builtin` lokal damit ist
sowohl die dev-DB aktuell als auch der nächste Image-Build.
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
(andere Ordner aus der ZIP entfernen vor dem Entpacken).
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
## Changelog
### 1.1.0 (2026-05-01)
**Production-readiness** die Version, die wirklich öffentlich gehen darf.
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
- 🔄 **Auto-Vertragsstatus**:
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
(mit Datums-Modal beim Upload)
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
Login transparent auf Cost 12 geupgradet.
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
über `tokenInvalidatedAt`.
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
path-to-regexp, undici, minimatch).
### 1.0.0
Erste Release-Version.
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
- Vertrags-Cockpit mit Rechnungsprüfung
- E-Mail-Client mit Anhang-Verwaltung
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
- Factory-Defaults für Stammdaten-Kataloge
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
## Lizenz
MIT
-13
View File
@@ -1,13 +0,0 @@
# Database - Root für Migrationen, opencrm-User für Runtime
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d"
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# Server
PORT=3001
NODE_ENV=development
+9 -1
View File
@@ -1,9 +1,17 @@
# Backend nutzt seit v1.1 die zentrale Root-.env im Projektverzeichnis.
# → siehe ../.env.example für alle Variablen
#
# Diese Datei bleibt als Legacy-Fallback: wenn /.env nicht existiert,
# liest das Backend backend/.env (z.B. für isolierte Backend-Tests).
# Database
DATABASE_URL="mysql://user:password@localhost:3306/opencrm"
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_EXPIRES_IN="7d"
# Access kurz (XSS-Schutz, nur JS-Memory). Refresh lang im httpOnly-Cookie.
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
# Encryption (for portal credentials)
ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
+7 -1
View File
@@ -4,10 +4,11 @@ node_modules/
# Build
dist/
# Environment
# Environment echte Secrets blocken, .env.example weiter mittracken
.env
.env.local
.env.*.local
!.env.example
# Database Backups (can be large, keep folder structure)
prisma/backups/*
@@ -17,6 +18,11 @@ prisma/backups/*
uploads/*
!uploads/.gitkeep
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
factory-defaults/*
!factory-defaults/.gitkeep
!factory-defaults/README.md
# Logs
*.log
npm-debug.log*
+77
View File
@@ -0,0 +1,77 @@
# Multi-Stage Build: Frontend bauen, dann Backend bauen, dann schlankes Runtime-Image
# ---------------------------------------------------------------------------------
# Alle Stages auf node:20-slim (Debian-basiert) dann passt die Prisma-Query-
# Engine (glibc + openssl) zur Runtime.
# ============== STAGE 1: Frontend bauen ==============
FROM node:20-slim AS frontend-builder
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund --prefer-offline
COPY frontend/ ./
RUN npm run build
# Output: /build/frontend/dist/
# ============== STAGE 2: Backend bauen (TS → JS) ==============
FROM node:20-slim AS backend-builder
WORKDIR /build/backend
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --no-audit --no-fund --prefer-offline
COPY backend/prisma ./prisma
RUN npx prisma generate
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npx tsc
# Output: /build/backend/dist/
# ============== STAGE 3: Runtime ==============
# Debian-slim statt Alpine: Prisma-Engines erwarten libssl 1.1, das in Alpine 3.19+
# nicht mehr verfügbar ist. Slim hat openssl 3 ABI-kompatibel + native binaries.
FROM node:20-slim
WORKDIR /app
# OpenSSL für Prisma-Query-Engine + wget für Healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget \
&& rm -rf /var/lib/apt/lists/*
# Nur Production-Dependencies + Prisma-Client
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund --prefer-offline && npm cache clean --force
# Build-Artefakte aus Stage 2
COPY --from=backend-builder /build/backend/dist ./dist
COPY --from=backend-builder /build/backend/node_modules/.prisma ./node_modules/.prisma
COPY --from=backend-builder /build/backend/node_modules/@prisma ./node_modules/@prisma
COPY backend/prisma ./prisma
# src/ mitkopieren, damit prisma/*.ts-Wartungsskripte (cleanup, reset-admin-
# password etc.) auch im Production-Container via `npx tsx` laufen können
# die importieren über '../src/lib/prisma.js' den shared Prisma-Client.
# Server selbst läuft weiter aus dist/.
COPY --from=backend-builder /build/backend/src ./src
COPY backend/tsconfig.json ./tsconfig.json
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
COPY --from=frontend-builder /build/frontend/dist ./public
# Eingebaute Werkseinstellungen ins Image: bei Erstinstallation (leerer DB) zieht
# der Entrypoint sie via tsx scripts/seed-factory-defaults.ts ein. Liegt in einem
# eigenen Pfad `factory-defaults/` selbst kann über Bind-Mount überlagert werden.
COPY backend/factory-defaults /app/factory-defaults-builtin
COPY backend/scripts /app/scripts
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
RUN mkdir -p uploads factory-defaults prisma/backups
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --tries=1 --spider "http://localhost:${PORT:-3001}/api/health" || exit 1
# Beim Start: prisma db push (idempotent), dann node
COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
# Container-Start:
# 1) Auf DB warten
# 2) Auto-Baseline für bestehende DBs (db-push-Ära ohne _prisma_migrations)
# 3) `prisma migrate deploy` (idempotent, datenerhaltend)
# 4) Auto-Seed bei leerer User-Tabelle (oder RUN_SEED=true)
# Neue Schema-Änderung anlegen (lokal, im Dev): npm run schema:sync
set -e
# DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
# Wichtig: encodeURIComponent für DB_USER + DB_PASSWORD, damit Sonderzeichen
# wie $, !, #, @, :, / etc. nicht die URL-Authority-Syntax brechen.
# Wir nutzen node-eval (ist eh installiert), kein extra-Tool wie jq nötig.
if [ -z "$DATABASE_URL" ] && [ -n "$DB_USER" ] && [ -n "$DB_PASSWORD" ] && [ -n "$DB_NAME" ]; then
DATABASE_URL=$(node -e "
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'db';
const port = process.env.DB_PORT || '3306';
const n = process.env.DB_NAME;
process.stdout.write(\`mysql://\${u}:\${p}@\${h}:\${port}/\${n}\`);
")
export DATABASE_URL
echo "[entrypoint] DATABASE_URL aus DB_*-Komponenten gebaut (host=${DB_HOST:-db})"
fi
echo "[entrypoint] Warte auf Datenbank…"
# Erst auf DB-Verfügbarkeit warten via einfachem Connect-Check.
# Wir nutzen Prisma's interne Engine, kein extra mysql-client nötig.
TRIES=30
until node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.\$queryRaw\`SELECT 1\`
.then(() => p.\$disconnect().then(() => process.exit(0)))
.catch(() => process.exit(1));
" 2>/dev/null; do
TRIES=$((TRIES - 1))
if [ "$TRIES" -le 0 ]; then
echo "[entrypoint] DB nicht erreichbar Abbruch"
exit 1
fi
echo "[entrypoint] DB noch nicht bereit retry in 2s ($TRIES Versuche übrig)"
sleep 2
done
echo "[entrypoint] DB erreichbar"
# Auto-Baseline: Wenn die DB Anwendungs-Tabellen enthält (z.B. User), aber noch
# keine _prisma_migrations-Tabelle, dann ist es eine "alte" DB, die früher mit
# `prisma db push` synced wurde. Wir markieren 0_init als bereits angewendet,
# damit `migrate deploy` nicht versucht, alle Tabellen nochmal anzulegen.
NEEDS_BASELINE=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
(async () => {
try {
const dbName = process.env.DB_NAME;
const tables = await p.\$queryRawUnsafe(
\`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?\`,
dbName
);
const names = tables.map(t => t.TABLE_NAME);
const hasMigrations = names.includes('_prisma_migrations');
const hasUserTable = names.includes('User');
// Existing DB (User da) ohne Migrations-Tracking => Baseline nötig
if (hasUserTable && !hasMigrations) process.stdout.write('yes');
else process.stdout.write('no');
} catch (e) {
process.stdout.write('no');
} finally {
await p.\$disconnect();
}
})();
" 2>/dev/null)
if [ "$NEEDS_BASELINE" = "yes" ]; then
echo "[entrypoint] Bestehende DB ohne Migrations-Tracking erkannt markiere 0_init als angewendet (Baseline)"
npx prisma migrate resolve --applied 0_init || echo "[entrypoint] Baseline fehlgeschlagen fahre trotzdem fort"
fi
# Migrations anwenden (idempotent: bereits angewendete werden übersprungen).
# Im Gegensatz zu `db push` löscht `migrate deploy` keine Daten — Schema-
# Änderungen werden über versionierte Migrations-Files unter prisma/migrations/
# eingespielt. Neue Migration anlegen mit: npm run schema:sync (lokal, dev).
echo "[entrypoint] Wende Migrations an…"
if ! npx prisma migrate deploy; then
echo "[entrypoint] migrate deploy fehlgeschlagen Abbruch"
exit 1
fi
echo "[entrypoint] DB-Schema aktuell"
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
USER_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.user.count()
.then((n) => { process.stdout.write(String(n)); process.exit(0); })
.catch(() => { process.stdout.write('-1'); process.exit(0); });
" 2>/dev/null)
RAN_SEED=false
if [ "${RUN_SEED:-false}" = "true" ]; then
echo "[entrypoint] RUN_SEED=true seede DB (Force)"
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Seed fehlgeschlagen oder schon gelaufen ignoriert"; fi
elif [ "$USER_COUNT" = "0" ]; then
echo "[entrypoint] DB ist leer (User-Count=0) Auto-Seed wird ausgeführt"
if npx prisma db seed; then RAN_SEED=true; else echo "[entrypoint] Auto-Seed fehlgeschlagen ignoriert"; fi
else
echo "[entrypoint] DB enthält $USER_COUNT User kein Seed nötig"
fi
# Eingebaute Factory-Defaults nach Erstinstallation einspielen.
# Das ist die Werkseinstellung für neue VMs: PDF-Vorlagen, Anbieter, Tarife,
# HTML-Templates alles aus /app/factory-defaults-builtin/. Erfolgt nur wenn
# der Auto-Seed gerade lief (= frische DB), sonst werden Updates auf
# bestehenden Installationen nicht ungewollt überschrieben.
if [ "$RAN_SEED" = "true" ] && [ -d /app/factory-defaults-builtin ] \
&& [ -n "$(ls -A /app/factory-defaults-builtin 2>/dev/null | grep -v -E '^(README\.md|\.gitkeep)$')" ]; then
echo "[entrypoint] Spiele eingebaute Factory-Defaults ein…"
FACTORY_DEFAULTS_DIR=/app/factory-defaults-builtin npx tsx scripts/seed-factory-defaults.ts \
|| echo "[entrypoint] Factory-Defaults-Seed fehlgeschlagen ignoriert"
fi
# Permissions + Rollen-Sync: Stellt sicher, dass nachträglich hinzugefügte
# Permissions (z.B. audit:read auf der DSGVO-Rolle) auch auf bestehenden
# DBs ankommen. Seed läuft NICHT auf nicht-leeren DBs, daher würden alte
# Installationen sonst mit unvollständigen Role-Perms laufen. Idempotent,
# fasst keine Stammdaten / User / Verträge an.
echo "[entrypoint] Rollen + Permissions synchronisieren…"
npx tsx prisma/sync-roles.ts \
|| echo "[entrypoint] Role-Sync fehlgeschlagen nicht kritisch"
# Datenbereinigung: XSS-Strings aus Customer/User-Stringfeldern strippen,
# nicht-whitelisted AppSettings entfernen, Pentest-Marker melden (Default
# nur warnen; CLEANUP_PURGE_PENTEST=true löscht markierte Records).
# Idempotent läuft bei jedem Container-Start ohne Risiko.
echo "[entrypoint] Datenbereinigung läuft…"
npx tsx prisma/cleanup-xss-and-mass-assignment.ts \
|| echo "[entrypoint] Cleanup übersprungen / fehlgeschlagen nicht kritisch"
echo "[entrypoint] Starte Backend…"
exec "$@"
View File
+290
View File
@@ -0,0 +1,290 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
Siehe auch den Abschnitt „Factory-Defaults" in der Haupt-[README.md](../../README.md)
für einen Gesamtüberblick und die Abgrenzung zum Datenbank-Backup.
---
## Inhalt
```
backend/factory-defaults/
├── providers/
│ └── providers.json # Anbieter inkl. Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen
│ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
├── pdf-templates/
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
│ └── *.pdf # PDF-Vorlagen-Dateien
└── app-settings/
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
# Vollmacht / Website-Datenschutz
```
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
Standardpflichten) andere Keys werden beim Import ignoriert.
---
## Export (aus einer bestehenden Installation)
1. Im CRM als Admin einloggen
2. **Einstellungen****Factory-Defaults**
3. Auf **„Factory-Defaults exportieren"** klicken
4. Die heruntergeladene ZIP (`factory-defaults-YYYY-MM-DD.zip`) speichern
### Inhalt der ZIP
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version, Datum, Einträge pro Kategorie
├── providers/providers.json
├── contract-meta/cancellation-periods.json
├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.json
├── pdf-templates/pdf-templates.json
├── pdf-templates/*.pdf
└── app-settings/app-settings.json
```
Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme,
neue Installationen oder Partner-Setups.
---
## Import (in eine andere Installation)
### Variante A: Über die UI (empfohlen)
1. Im Ziel-CRM als Admin einloggen
2. **Einstellungen → Factory-Defaults**
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
4. Die exportierte ZIP wählen der Import läuft direkt
5. Erfolgsmeldung zeigt Counts pro Kategorie an
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
Unterordnerstruktur beibehalten
3. **Script ausführen:**
```bash
cd backend
npm run seed:defaults
```
4. **Ausgabe prüfen** bei Erfolg:
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Idempotenz
Das Script nutzt ausschließlich `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (match per unique key: `name` / `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` **beliebig oft ausführen** kein Datenverlust,
keine Duplikate.
### Was passiert mit den PDF-Dateien?
Die PDFs aus `pdf-templates/*.pdf` werden beim Import nach
`backend/uploads/pdf-templates/` kopiert (mit eindeutigem Zeitstempel-Suffix).
Die Pfade in der DB werden automatisch auf die neue Kopie gesetzt.
Beim Re-Import einer bereits existierenden Vorlage wird die alte Datei in `uploads/`
entsorgt und durch die neue ersetzt.
---
## Mehrere Exporte mergen
Wenn du mehrere ZIPs hast (z.B. "Verivox-Paket", "Check24-Paket", "eigene"), kannst
du die JSON-Dateien frei benennen und in einen Ordner legen. Das Script liest
alle `*.json` im jeweiligen Unterordner und merged den Inhalt zusammen.
**Beispiel:**
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus dem Verivox-Paket
check24.json # 30 Anbieter aus dem Check24-Paket
eigene.json # 5 eigene, firmenspezifische Anbieter
contract-meta/
standard.json # Standard-Kündigungsfristen + Laufzeiten + Kategorien
pdf-templates/
ewe-paket.json # EWE-Vorlage
moon-paket.json # Moon-Vorlage
ewe-auftrag.pdf
moon-formular.pdf
```
Bei gleichem Unique-Key (z.B. `providers.name: "EWE"` in mehreren Dateien) gewinnt
der zuletzt gelesene Eintrag.
---
## Teil-Import (nur Kategorien auswählen)
Falls du nur einen Teil importieren willst (z.B. nur PDF-Vorlagen ohne Anbieter),
lösche oder verschiebe einfach die nicht gewünschten JSON-Dateien, bevor du das
Script ausführst. Das Script überspringt Kategorien ohne Dateien ohne Fehler.
**Beispiel: nur PDF-Vorlagen:**
```
backend/factory-defaults/
pdf-templates/ # nur diesen Ordner behalten
pdf-templates.json
*.pdf
```
---
## Struktur-Referenz (für manuelle Pflege)
### `providers/providers.json`
Array von Providern, jeweils inkl. zugehöriger Tarife:
```json
[
{
"name": "EWE",
"portalUrl": "https://www.ewe.de/privatkunden/meine-ewe/login",
"usernameFieldName": "username",
"passwordFieldName": "password",
"isActive": true,
"tariffs": [
{ "name": "EWE Zuhause Strom", "isActive": true },
{ "name": "EWE Zuhause Gas", "isActive": true }
]
}
]
```
**Unique Key:** `name`
### `contract-meta/cancellation-periods.json`
```json
[
{ "code": "14T", "description": "14 Tage", "isActive": true },
{ "code": "1M", "description": "1 Monat", "isActive": true },
{ "code": "3M", "description": "3 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-durations.json`
```json
[
{ "code": "12M", "description": "12 Monate", "isActive": true },
{ "code": "24M", "description": "24 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-categories.json`
```json
[
{
"code": "ELECTRICITY",
"name": "Strom",
"icon": "Zap",
"color": "#FFC107",
"sortOrder": 1,
"isActive": true
}
]
```
**Unique Key:** `code`
### `pdf-templates/pdf-templates.json`
```json
[
{
"name": "EWE Auftragsformular",
"description": "Auftrag für Glasfaser-Anschluss",
"providerName": "EWE",
"originalName": "EWE-Auftrag-Privat.pdf",
"fieldMapping": {
"Vorname": "customer.firstName",
"Nachname": "customer.lastName",
"Strasse": "address.streetFull",
"PLZ": "address.postalCode",
"Ort": "address.city"
},
"phoneFieldPrefix": "Rufnummer",
"maxPhoneFields": 8,
"isActive": true,
"pdfFilename": "EWE_Auftragsformular.pdf"
}
]
```
**Unique Key:** `name`
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
### `app-settings/app-settings.json`
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv andere Keys
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
```json
[
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
]
```
**Unique Key:** `key`
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
`websitePrivacyPolicyHtml`.
---
## Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
---
## Git & Versionierung
Dieser Ordner ist in `.gitignore` eingetragen (außer `.gitkeep` und `README.md`),
damit firmen-spezifische Exporte nicht versehentlich ins Repo kommen.
Wenn du **öffentlich teilbare Katalog-Pakete** versionieren willst, lege sie
außerhalb dieses Ordners ab (z.B. in einem eigenen Repository) und kopiere sie
bei Bedarf hierher.
+248 -101
View File
@@ -1,28 +1,35 @@
{
"name": "opencrm-backend",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencrm-backend",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -35,10 +42,10 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
},
@@ -49,7 +56,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@@ -65,7 +71,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -81,7 +86,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -97,7 +101,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@@ -113,7 +116,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -129,7 +131,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@@ -145,7 +146,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -161,7 +161,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@@ -177,7 +176,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -193,7 +191,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -209,7 +206,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -225,7 +221,6 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -241,7 +236,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -257,7 +251,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -273,7 +266,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -289,7 +281,6 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -305,7 +296,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -321,7 +311,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -337,7 +326,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@@ -353,7 +341,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -369,7 +356,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@@ -385,7 +371,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
@@ -401,7 +386,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@@ -417,7 +401,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -433,7 +416,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -449,7 +431,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@@ -507,7 +488,8 @@
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -628,7 +610,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
@@ -638,11 +619,19 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -656,7 +645,6 @@
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -668,7 +656,6 @@
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
@@ -679,8 +666,7 @@
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
@@ -717,8 +703,7 @@
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"node_modules/@types/ms": {
"version": "2.1.0",
@@ -739,11 +724,17 @@
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
@@ -765,14 +756,12 @@
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
},
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
@@ -787,7 +776,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
@@ -796,7 +784,6 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
@@ -807,7 +794,6 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
@@ -975,6 +961,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
@@ -1058,9 +1045,10 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -1263,6 +1251,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -1449,6 +1456,33 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-13.0.0.tgz",
"integrity": "sha512-aBfBS8eYIeXmpHI9ThIlA7/WLq+SLt18iXUZhb52rW89QLKQFoIpPG1bPeewoPZsTyjSSO3T7234FBVUM1V2rA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^17.4.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand/node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1543,7 +1577,6 @@
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@@ -1662,6 +1695,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz",
"integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
@@ -1752,7 +1803,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -1809,7 +1859,6 @@
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@@ -1882,6 +1931,15 @@
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@@ -1965,19 +2023,31 @@
]
},
"node_modules/imapflow": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"@zone-eu/mailsplit": "5.4.9",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libmime": "5.3.8",
"libqp": "2.1.1",
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
"nodemailer": "8.0.7",
"pino": "10.3.1",
"socks": "2.8.8"
}
},
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
"version": "5.4.9",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
@@ -1995,6 +2065,27 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2187,9 +2278,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@@ -2232,18 +2324,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/mailparser": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.7",
"libmime": "5.3.8",
"linkify-it": "5.0.0",
"nodemailer": "7.0.13",
"nodemailer": "8.0.5",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
@@ -2263,6 +2356,27 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2326,11 +2440,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2397,6 +2512,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -2436,6 +2560,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -2505,9 +2630,10 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
@@ -2554,9 +2680,10 @@
}
},
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
@@ -2578,6 +2705,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
@@ -2585,7 +2713,8 @@
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/png-js": {
"version": "1.0.0",
@@ -2637,7 +2766,8 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
@@ -2660,9 +2790,10 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -2676,7 +2807,8 @@
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
@@ -2728,9 +2860,10 @@
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -2742,6 +2875,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
@@ -2750,7 +2884,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
@@ -2783,6 +2916,7 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
@@ -2963,17 +3097,19 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -2981,10 +3117,20 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socks/node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
@@ -2993,6 +3139,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
@@ -3146,6 +3293,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
@@ -3183,7 +3331,6 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3234,9 +3381,10 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
@@ -3244,8 +3392,7 @@
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/unicode-properties": {
"version": "1.4.1",
+13 -4
View File
@@ -1,10 +1,10 @@
{
"name": "opencrm-backend",
"version": "1.0.0",
"version": "1.1.0",
"description": "OpenCRM Backend API",
"main": "dist/index.js",
"prisma": {
"seed": "tsx prisma/seed.ts"
"seed": "npx tsx prisma/seed.ts"
},
"scripts": {
"dev": "tsx watch src/index.ts",
@@ -12,27 +12,36 @@
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"schema:sync": "prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
"db:restore": "tsx prisma/restore-data.ts"
"db:restore": "tsx prisma/restore-data.ts",
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv-expand": "^13.0.0",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"tsx": "^4.19.2",
"undici": "^6.23.0"
},
"devDependencies": {
@@ -45,10 +54,10 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
+51 -24
View File
@@ -1,12 +1,15 @@
/**
* Datenbank-Backup Script
*
* Exportiert alle Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
* Exportiert ALLE Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
*
* Verwendung:
* npx ts-node prisma/backup-data.ts
* npm run db:backup
*
* Erstellt einen Ordner 'backups/YYYY-MM-DD_HH-mm-ss/' mit JSON-Dateien pro Tabelle.
* Erstellt einen Ordner 'prisma/backups/YYYY-MM-DDTHH-mm-ss/' mit JSON-Dateien pro Tabelle.
*
* Die Tabellen sind nach Abhängigkeitsreihenfolge sortiert (Level 0 = keine FKs, dann aufsteigend).
* Damit kann das Restore-Script sie in der gleichen Reihenfolge einspielen, ohne FK-Verletzungen.
*/
import { PrismaClient } from '@prisma/client';
@@ -28,7 +31,7 @@ async function main() {
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
const tables = [
// Level 0: Keine Abhängigkeiten
// ============ Level 0: Reine Stammdaten/Kataloge ============
{ name: 'Permission', query: () => prisma.permission.findMany() },
{ name: 'Role', query: () => prisma.role.findMany() },
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
@@ -37,40 +40,58 @@ async function main() {
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
{ name: 'EnergyProvider', query: () => prisma.energyProvider.findMany() },
{ name: 'TelecomProvider', query: () => prisma.telecomProvider.findMany() },
{ name: 'Provider', query: () => prisma.provider.findMany() },
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
// Level 1: Abhängig von Level 0
// ============ Level 1: Abhängig von Level 0 ============
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
{ name: 'User', query: () => prisma.user.findMany() },
{ name: 'Customer', query: () => prisma.customer.findMany() },
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
// Level 2: Abhängig von Level 1
// ============ Level 2: Abhängig von Customer ============
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'Contract', query: () => prisma.contract.findMany() },
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
// Level 3: Abhängig von Level 2
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
{ name: 'ContractNote', query: () => prisma.contractNote.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
// ============ Level 3: Contracts + abhängige ============
{ name: 'Contract', query: () => prisma.contract.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
// Level 4: Abhängig von Level 3
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
// Vertragstyp-spezifische Details
// ============ Level 4: Vertragstyp-Details + Sub-Tabellen ============
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'TelecomContractDetails', query: () => prisma.telecomContractDetails.findMany() },
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'ContractHistoryEntry', query: () => prisma.contractHistoryEntry.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'Invoice', query: () => prisma.invoice.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
// ============ Level 5: Sub-Tabellen der Sub-Tabellen ============
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
// ============ Level 6: Logs & Emails (wachsende Tabellen) ============
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
];
let totalRecords = 0;
const stats: { table: string; count: number }[] = [];
const skipped: string[] = [];
for (const table of tables) {
try {
@@ -79,7 +100,7 @@ async function main() {
totalRecords += count;
stats.push({ table: table.name, count });
// JSON-Datei schreiben
// JSON-Datei schreiben (Date-Felder als ISO-String)
const filePath = path.join(backupDir, `${table.name}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
@@ -87,20 +108,26 @@ async function main() {
console.log(`${status} ${table.name}: ${count} Einträge`);
} catch (error: any) {
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 50)}...)`);
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 80)}...)`);
}
}
// Backup-Info speichern
const backupInfo = {
timestamp: new Date().toISOString(),
schemaVersion: 'current',
totalRecords,
tables: stats,
skippedTables: skipped,
};
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
console.log(`\n✅ Backup abgeschlossen!`);
console.log(` 📊 ${totalRecords} Datensätze in ${stats.filter(s => s.count > 0).length} Tabellen`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen übersprungen: ${skipped.join(', ')}`);
}
console.log(` 📁 Gespeichert in: ${backupDir}\n`);
}
@@ -0,0 +1,351 @@
/**
* Einmal-Bereinigung für Pentest-Reste (Runde 12 / 2026-05-18):
*
* 1. HTML-Tags aus Customer/User-Stringfeldern strippen (M2-Stored-XSS-Reste)
* 2. Unbekannte App-Settings entfernen, die durch Mass-Assignment in die DB
* gerutscht sind, BEVOR die Whitelist eingezogen wurde (M1-Reste).
*
* Idempotent: wenn nichts zu tun ist, ändert sich nichts. Bei Bedarf
* mehrfach aufrufbar.
*/
import prisma from '../src/lib/prisma.js';
import { stripHtml } from '../src/utils/sanitize.js';
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
const CUSTOMER_STRING_FIELDS = [
'salutation', 'firstName', 'lastName', 'companyName',
'birthPlace', 'email', 'phone', 'mobile',
'taxNumber', 'commercialRegisterNumber', 'notes',
];
const USER_STRING_FIELDS = [
'firstName', 'lastName', 'email',
'whatsappNumber', 'telegramUsername', 'signalNumber',
];
async function cleanupXss() {
const customers = await prisma.customer.findMany();
let touched = 0;
for (const c of customers) {
const updates: Record<string, string> = {};
for (const field of CUSTOMER_STRING_FIELDS) {
const v = (c as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` Customer #${c.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.customer.update({ where: { id: c.id }, data: updates });
touched++;
}
}
console.log(` → Customer bereinigt: ${touched}`);
const users = await prisma.user.findMany();
let userTouched = 0;
for (const u of users) {
const updates: Record<string, string> = {};
for (const field of USER_STRING_FIELDS) {
const v = (u as any)[field];
if (typeof v === 'string') {
const cleaned = stripHtml(v) as string;
if (cleaned !== v) updates[field] = cleaned;
}
}
if (Object.keys(updates).length > 0) {
console.log(` User #${u.id}: bereinigt:`, Object.keys(updates).join(', '));
await prisma.user.update({ where: { id: u.id }, data: updates });
userTouched++;
}
}
console.log(` → User bereinigt: ${userTouched}`);
}
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
const HTML_ALLOWED_SETTING_KEYS = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
function stripHtmlString(s: string): string {
return s
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/(?:javascript|data|vbscript)\s*:/gi, 'blocked:');
}
// Legitime CustomerConsent.source-Werte. Alles andere wird beim Cleanup
// auf 'unknown' normalisiert. Pentest 2026-05-20.
const ALLOWED_CONSENT_SOURCES = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
// raus (Pentest 2026-05-20 LOW 27.1).
function isValidDocumentPath(v: string | null | undefined): boolean {
if (!v) return true; // null/leer ist OK
if (v.includes('..')) return false;
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
}
async function cleanupConsents() {
// version + documentPath: HTML strippen (waren ohne Validierung).
// source: Whitelist erzwingen.
// documentPath zusätzlich gegen Pfad-Traversal absichern (27.1).
let versionStripped = 0;
let pathNulled = 0;
let sourceFixed = 0;
const consents = await prisma.customerConsent.findMany({
select: { id: true, source: true, documentPath: true, version: true },
});
for (const c of consents) {
const data: Record<string, string | null> = {};
if (c.version && c.version !== stripHtmlString(c.version)) {
data.version = stripHtmlString(c.version);
versionStripped++;
}
if (c.documentPath && !isValidDocumentPath(c.documentPath)) {
// ".../etc/passwd", "<script>", "javascript:..." etc. → NULL.
// Legitime Uploads bleiben unberührt (siehe isValidDocumentPath).
data.documentPath = null;
pathNulled++;
}
if (c.source && !ALLOWED_CONSENT_SOURCES.has(c.source)) {
data.source = 'unknown';
sourceFixed++;
}
if (Object.keys(data).length > 0) {
await prisma.customerConsent.update({ where: { id: c.id }, data });
}
}
console.log(
` → Consent bereinigt: version-stripped=${versionStripped}, ` +
`documentPath-genullt=${pathNulled}, source-whitelist=${sourceFixed}`,
);
}
// documentPath in den weiteren Tabellen prüfen. Schreibend wird er
// server-seitig vom multer-Upload erzeugt falls dort doch mal ein
// dreckiger Wert reingerutscht ist (z.B. aus einem importierten Backup
// vor unseren Sanitization-Fixes), nullen wir ihn hier raus.
// ContractDocument hat documentPath NOT NULL → wir berichten dort nur,
// löschen aber nicht (Records müssten manuell angeschaut werden).
async function cleanupDocumentPaths() {
const findings: { table: string; id: number; value: string }[] = [];
const optional: Array<{
label: string;
fetch: () => Promise<{ id: number; documentPath: string | null }[]>;
update: (id: number) => Promise<unknown>;
}> = [
{
label: 'BankCard',
fetch: () => prisma.bankCard.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.bankCard.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'IdentityDocument',
fetch: () => prisma.identityDocument.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.identityDocument.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'Invoice',
fetch: () => prisma.invoice.findMany({ select: { id: true, documentPath: true } }),
update: (id) => prisma.invoice.update({ where: { id }, data: { documentPath: null } }),
},
{
label: 'RepresentativeAuthorization',
fetch: () => prisma.representativeAuthorization.findMany({
select: { id: true, documentPath: true },
}),
update: (id) => prisma.representativeAuthorization.update({
where: { id }, data: { documentPath: null },
}),
},
];
let nulled = 0;
for (const t of optional) {
const rows = await t.fetch();
for (const r of rows) {
if (r.documentPath && !isValidDocumentPath(r.documentPath)) {
findings.push({ table: t.label, id: r.id, value: r.documentPath.slice(0, 80) });
await t.update(r.id);
nulled++;
}
}
}
// ContractDocument: documentPath ist NOT NULL → wir berichten nur.
const contractDocs = await prisma.contractDocument.findMany({
select: { id: true, documentPath: true },
});
let contractDocsDirty = 0;
for (const d of contractDocs) {
if (!isValidDocumentPath(d.documentPath)) {
findings.push({ table: 'ContractDocument', id: d.id, value: d.documentPath.slice(0, 80) });
contractDocsDirty++;
}
}
console.log(` → documentPath bereinigt: ${nulled} genullt, ${contractDocsDirty} ContractDocument-Records auffällig (NOT NULL, manuell prüfen)`);
for (const f of findings.slice(0, 10)) {
console.log(` [${f.table}#${f.id}] "${f.value}"`);
}
}
async function reportOrphanedUsers() {
// User ohne jegliche Rollenzuordnung können sich zwar einloggen, sind aber
// im Permission-System unsichtbar. Meist Überrest von gescheiterten Seeds
// oder manuellen DB-Edits. Wir löschen NICHT (könnte legitime
// Spezial-User treffen) nur warnen.
const orphans = await prisma.user.findMany({
where: { roles: { none: {} } },
select: { id: true, email: true, createdAt: true },
});
if (orphans.length === 0) {
console.log(' → Keine User ohne Rollenzuordnung.');
return;
}
console.log(` ⚠️ ${orphans.length} User ohne Rollenzuordnung:`);
for (const u of orphans.slice(0, 10)) {
console.log(` [User#${u.id}] ${u.email} (created ${u.createdAt.toISOString()})`);
}
console.log(' → Rolle zuweisen oder User löschen.');
}
async function cleanupAppSettings() {
const settings = await prisma.appSetting.findMany();
const removed: string[] = [];
let stripped = 0;
for (const s of settings) {
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
removed.push(s.key);
await prisma.appSetting.delete({ where: { key: s.key } });
continue;
}
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
const cleaned = stripHtmlString(s.value);
if (cleaned !== s.value) {
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
stripped++;
}
}
}
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
if (stripped > 0) {
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
}
}
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
// gefasst legitime Kunden mit "Hacker" als Nachnamen sollen nicht
// fälschlich getroffen werden (gibt's reichlich, gerade hier).
// Konkret weggelassen: `^hacker@` würde Verwandte/Kunden mit
// `hacker@familie-hacker.de` o.ä. fängen.
const PENTEST_MARKERS = [
/@evil\./i,
/^attacker@/i,
/^pentest@/i,
/<script\b/i, // unverwechselbarer XSS-Marker
/\bonerror\s*=/i, // <img onerror=…>
/javascript:/i, // javascript:-URL
/'\s*OR\s*'1'\s*=\s*'1/i, // SQL-Injection
/\.\.\/.*etc\/passwd/i, // Path-Traversal
];
function looksLikePentestData(value: unknown): boolean {
if (typeof value !== 'string') return false;
return PENTEST_MARKERS.some((re) => re.test(value));
}
async function findOrPurgePentestRecords() {
const purge = process.env.CLEANUP_PURGE_PENTEST === 'true';
const suspect: Array<{ kind: string; id: number; reason: string }> = [];
const customers = await prisma.customer.findMany();
for (const c of customers) {
for (const f of ['email', 'phone', 'mobile', 'firstName', 'lastName', 'companyName', 'notes']) {
if (looksLikePentestData((c as any)[f])) {
suspect.push({ kind: 'Customer', id: c.id, reason: `${f}=${JSON.stringify((c as any)[f]).slice(0, 60)}` });
break;
}
}
}
const users = await prisma.user.findMany();
for (const u of users) {
for (const f of ['email', 'firstName', 'lastName']) {
if (looksLikePentestData((u as any)[f])) {
suspect.push({ kind: 'User', id: u.id, reason: `${f}=${JSON.stringify((u as any)[f]).slice(0, 60)}` });
break;
}
}
}
if (suspect.length === 0) {
console.log(' → Keine Pentest-Marker in Customer/User-Records gefunden.');
return;
}
console.log(`${suspect.length} verdächtige Records (Pentest-Marker):`);
for (const s of suspect) {
console.log(` [${s.kind}#${s.id}] ${s.reason}`);
}
if (!purge) {
console.log(' ️ Zum Löschen Container mit CLEANUP_PURGE_PENTEST=true neu starten,');
console.log(' oder Records manuell über adminer entfernen.');
return;
}
for (const s of suspect) {
if (s.kind === 'Customer') {
await prisma.customer.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [Customer#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
} else if (s.kind === 'User') {
await prisma.user.delete({ where: { id: s.id } }).catch((e: any) => {
console.log(` [User#${s.id}] Löschen fehlgeschlagen: ${e.message?.slice(0, 80)}`);
});
}
}
console.log(`${suspect.length} verdächtige Records gelöscht.`);
}
async function main() {
console.log('=== Cleanup: XSS-Reste + Mass-Assignment-AppSettings ===');
await cleanupXss();
await cleanupAppSettings();
await cleanupConsents();
await cleanupDocumentPaths();
await reportOrphanedUsers();
await findOrPurgePentestRecords();
console.log('=== Fertig. ===');
}
main()
.catch((e) => {
console.error('Cleanup fehlgeschlagen:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,989 @@
-- CreateTable
CREATE TABLE `PdfTemplate` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`providerName` VARCHAR(191) NULL,
`templatePath` VARCHAR(191) NOT NULL,
`originalName` VARCHAR(191) NOT NULL,
`fieldMapping` LONGTEXT NOT NULL,
`phoneFieldPrefix` VARCHAR(191) NULL,
`maxPhoneFields` INTEGER NULL DEFAULT 8,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `PdfTemplate_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`fromAddress` VARCHAR(191) NOT NULL,
`toAddress` VARCHAR(191) NOT NULL,
`subject` VARCHAR(191) NOT NULL,
`context` VARCHAR(191) NOT NULL,
`customerId` INTEGER NULL,
`triggeredBy` VARCHAR(191) NULL,
`smtpServer` VARCHAR(191) NOT NULL,
`smtpPort` INTEGER NOT NULL,
`smtpEncryption` VARCHAR(191) NOT NULL,
`smtpUser` VARCHAR(191) NOT NULL,
`success` BOOLEAN NOT NULL,
`messageId` VARCHAR(191) NULL,
`errorMessage` TEXT NULL,
`smtpResponse` TEXT NULL,
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `EmailLog_sentAt_idx`(`sentAt`),
INDEX `EmailLog_customerId_idx`(`customerId`),
INDEX `EmailLog_success_idx`(`success`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AppSetting` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AppSetting_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`tokenInvalidatedAt` DATETIME(3) NULL,
`passwordResetToken` VARCHAR(191) NULL,
`passwordResetExpiresAt` DATETIME(3) NULL,
`whatsappNumber` VARCHAR(191) NULL,
`telegramUsername` VARCHAR(191) NULL,
`signalNumber` VARCHAR(191) NULL,
`customerId` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_passwordResetToken_key`(`passwordResetToken`),
UNIQUE INDEX `User_customerId_key`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Role_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Permission` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resource` VARCHAR(191) NOT NULL,
`action` VARCHAR(191) NOT NULL,
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`roleId` INTEGER NOT NULL,
`permissionId` INTEGER NOT NULL,
PRIMARY KEY (`roleId`, `permissionId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `UserRole` (
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,
PRIMARY KEY (`userId`, `roleId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Customer` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerNumber` VARCHAR(191) NOT NULL,
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
`salutation` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`companyName` VARCHAR(191) NULL,
`foundingDate` DATETIME(3) NULL,
`birthDate` DATETIME(3) NULL,
`birthPlace` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`mobile` VARCHAR(191) NULL,
`taxNumber` VARCHAR(191) NULL,
`businessRegistrationPath` VARCHAR(191) NULL,
`commercialRegisterPath` VARCHAR(191) NULL,
`commercialRegisterNumber` VARCHAR(191) NULL,
`privacyPolicyPath` VARCHAR(191) NULL,
`consentHash` VARCHAR(191) NULL,
`notes` TEXT NULL,
`portalEnabled` BOOLEAN NOT NULL DEFAULT false,
`portalEmail` VARCHAR(191) NULL,
`portalPasswordHash` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`portalLastLogin` DATETIME(3) NULL,
`portalPasswordResetToken` VARCHAR(191) NULL,
`portalPasswordResetExpiresAt` DATETIME(3) NULL,
`portalTokenInvalidatedAt` DATETIME(3) NULL,
`lastBirthdayGreetingYear` INTEGER NULL,
`useInformalAddress` BOOLEAN NOT NULL DEFAULT false,
`autoBirthdayGreeting` BOOLEAN NOT NULL DEFAULT false,
`autoBirthdayChannel` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
UNIQUE INDEX `Customer_consentHash_key`(`consentHash`),
UNIQUE INDEX `Customer_portalEmail_key`(`portalEmail`),
UNIQUE INDEX `Customer_portalPasswordResetToken_key`(`portalPasswordResetToken`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerRepresentative` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`notes` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RepresentativeAuthorization` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`isGranted` BOOLEAN NOT NULL DEFAULT false,
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `RepresentativeAuthorization_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Address` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
`street` VARCHAR(191) NOT NULL,
`houseNumber` VARCHAR(191) NOT NULL,
`postalCode` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`ownerCompany` VARCHAR(191) NULL,
`ownerFirstName` VARCHAR(191) NULL,
`ownerLastName` VARCHAR(191) NULL,
`ownerStreet` VARCHAR(191) NULL,
`ownerHouseNumber` VARCHAR(191) NULL,
`ownerPostalCode` VARCHAR(191) NULL,
`ownerCity` VARCHAR(191) NULL,
`ownerPhone` VARCHAR(191) NULL,
`ownerMobile` VARCHAR(191) NULL,
`ownerEmail` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `BankCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`accountHolder` VARCHAR(191) NOT NULL,
`iban` VARCHAR(191) NOT NULL,
`bic` VARCHAR(191) NULL,
`bankName` VARCHAR(191) NULL,
`expiryDate` DATETIME(3) NULL,
`documentPath` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `IdentityDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
`documentNumber` VARCHAR(191) NOT NULL,
`issuingAuthority` VARCHAR(191) NULL,
`issueDate` DATETIME(3) NULL,
`expiryDate` DATETIME(3) NULL,
`documentPath` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`licenseClasses` VARCHAR(191) NULL,
`licenseIssueDate` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailProviderConfig` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
`apiUrl` VARCHAR(191) NOT NULL,
`apiKey` VARCHAR(191) NULL,
`username` VARCHAR(191) NULL,
`passwordEncrypted` VARCHAR(191) NULL,
`domain` VARCHAR(191) NOT NULL,
`defaultForwardEmail` VARCHAR(191) NULL,
`imapServer` VARCHAR(191) NULL,
`imapPort` INTEGER NULL DEFAULT 993,
`smtpServer` VARCHAR(191) NULL,
`smtpPort` INTEGER NULL DEFAULT 465,
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
`systemEmailAddress` VARCHAR(191) NULL,
`systemEmailPasswordEncrypted` VARCHAR(191) NULL,
`customerEmailLabel` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `StressfreiEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`email` VARCHAR(191) NOT NULL,
`platform` VARCHAR(191) NULL,
`notes` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
`provisionedAt` DATETIME(3) NULL,
`provisionError` TEXT NULL,
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
`emailPasswordEncrypted` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CachedEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`stressfreiEmailId` INTEGER NOT NULL,
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
`messageId` VARCHAR(191) NOT NULL,
`uid` INTEGER NOT NULL,
`subject` VARCHAR(191) NULL,
`fromAddress` VARCHAR(191) NOT NULL,
`fromName` VARCHAR(191) NULL,
`toAddresses` TEXT NOT NULL,
`ccAddresses` TEXT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`textBody` LONGTEXT NULL,
`htmlBody` LONGTEXT NULL,
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
`attachmentNames` TEXT NULL,
`contractId` INTEGER NULL,
`assignedAt` DATETIME(3) NULL,
`assignedBy` INTEGER NULL,
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
`isRead` BOOLEAN NOT NULL DEFAULT false,
`isStarred` BOOLEAN NOT NULL DEFAULT false,
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
`deletedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CachedEmail_contractId_idx`(`contractId`),
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Meter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`meterNumber` VARCHAR(191) NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
`tariffModel` ENUM('SINGLE', 'DUAL') NOT NULL DEFAULT 'SINGLE',
`location` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MeterReading` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`meterId` INTEGER NOT NULL,
`readingDate` DATETIME(3) NOT NULL,
`value` DOUBLE NOT NULL,
`valueNt` DOUBLE NULL,
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
`notes` VARCHAR(191) NULL,
`reportedBy` VARCHAR(191) NULL,
`status` ENUM('RECORDED', 'REPORTED', 'TRANSFERRED') NOT NULL DEFAULT 'RECORDED',
`transferredAt` DATETIME(3) NULL,
`transferredBy` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SalesPlatform` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`contactInfo` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CancellationPeriod` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractDuration` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractDuration_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Provider` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`portalUrl` VARCHAR(191) NULL,
`usernameFieldName` VARCHAR(191) NULL,
`passwordFieldName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Provider_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Tariff` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`providerId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractCategory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`icon` VARCHAR(191) NULL,
`color` VARCHAR(191) NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractCategory_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Contract` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractNumber` VARCHAR(191) NOT NULL,
`customerId` INTEGER NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT',
`contractCategoryId` INTEGER NULL,
`addressId` INTEGER NULL,
`billingAddressId` INTEGER NULL,
`bankCardId` INTEGER NULL,
`identityDocumentId` INTEGER NULL,
`salesPlatformId` INTEGER NULL,
`cancellationPeriodId` INTEGER NULL,
`contractDurationId` INTEGER NULL,
`previousContractId` INTEGER NULL,
`previousProviderId` INTEGER NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
`previousContractNumber` VARCHAR(191) NULL,
`providerId` INTEGER NULL,
`tariffId` INTEGER NULL,
`providerName` VARCHAR(191) NULL,
`tariffName` VARCHAR(191) NULL,
`customerNumberAtProvider` VARCHAR(191) NULL,
`contractNumberAtProvider` VARCHAR(191) NULL,
`priceFirst12Months` VARCHAR(191) NULL,
`priceFrom13Months` VARCHAR(191) NULL,
`priceAfter24Months` VARCHAR(191) NULL,
`startDate` DATETIME(3) NULL,
`endDate` DATETIME(3) NULL,
`commission` DOUBLE NULL,
`cancellationLetterPath` VARCHAR(191) NULL,
`cancellationConfirmationPath` VARCHAR(191) NULL,
`cancellationLetterOptionsPath` VARCHAR(191) NULL,
`cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
`cancellationConfirmationDate` DATETIME(3) NULL,
`cancellationConfirmationOptionsDate` DATETIME(3) NULL,
`wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false,
`portalUsername` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`stressfreiEmailId` INTEGER NULL,
`nextReviewDate` DATETIME(3) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`documentType` VARCHAR(191) NOT NULL,
`documentPath` VARCHAR(191) NOT NULL,
`originalName` VARCHAR(191) NOT NULL,
`notes` TEXT NULL,
`uploadedBy` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ContractDocument_contractId_idx`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractHistoryEntry` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTaskSubtask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EnergyContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`meterId` INTEGER NULL,
`maloId` VARCHAR(191) NULL,
`annualConsumption` DOUBLE NULL,
`annualConsumptionKwh` DOUBLE NULL,
`basePrice` DOUBLE NULL,
`unitPrice` DOUBLE NULL,
`unitPriceNt` DOUBLE NULL,
`bonus` DOUBLE NULL,
`previousProviderName` VARCHAR(191) NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractMeter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NOT NULL,
`meterId` INTEGER NOT NULL,
`position` INTEGER NOT NULL DEFAULT 0,
`installedAt` DATETIME(3) NULL,
`removedAt` DATETIME(3) NULL,
`finalReading` DOUBLE NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ContractMeter_energyContractDetailsId_idx`(`energyContractDetailsId`),
UNIQUE INDEX `ContractMeter_energyContractDetailsId_meterId_key`(`energyContractDetailsId`, `meterId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Invoice` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NULL,
`contractId` INTEGER NULL,
`invoiceDate` DATETIME(3) NOT NULL,
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
`documentPath` VARCHAR(191) NULL,
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
INDEX `Invoice_contractId_idx`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `InternetContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`downloadSpeed` INTEGER NULL,
`uploadSpeed` INTEGER NULL,
`routerModel` VARCHAR(191) NULL,
`routerSerialNumber` VARCHAR(191) NULL,
`installationDate` DATETIME(3) NULL,
`internetUsername` VARCHAR(191) NULL,
`internetPasswordEncrypted` VARCHAR(191) NULL,
`propertyType` VARCHAR(191) NULL,
`propertyLocation` VARCHAR(191) NULL,
`connectionLocation` VARCHAR(191) NULL,
`homeId` VARCHAR(191) NULL,
`activationCode` VARCHAR(191) NULL,
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PhoneNumber` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`internetContractDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NOT NULL,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`sipUsername` VARCHAR(191) NULL,
`sipPasswordEncrypted` VARCHAR(191) NULL,
`sipServer` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MobileContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`requiresMultisim` BOOLEAN NOT NULL DEFAULT false,
`dataVolume` DOUBLE NULL,
`includedMinutes` INTEGER NULL,
`includedSMS` INTEGER NULL,
`deviceModel` VARCHAR(191) NULL,
`deviceImei` VARCHAR(191) NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SimCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`mobileDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`pin` VARCHAR(191) NULL,
`puk` VARCHAR(191) NULL,
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TvContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`receiverModel` VARCHAR(191) NULL,
`smartcardNumber` VARCHAR(191) NULL,
`package` VARCHAR(191) NULL,
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CarInsuranceDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`licensePlate` VARCHAR(191) NULL,
`hsn` VARCHAR(191) NULL,
`tsn` VARCHAR(191) NULL,
`vin` VARCHAR(191) NULL,
`vehicleType` VARCHAR(191) NULL,
`firstRegistration` DATETIME(3) NULL,
`noClaimsClass` VARCHAR(191) NULL,
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
`deductiblePartial` DOUBLE NULL,
`deductibleFull` DOUBLE NULL,
`policyNumber` VARCHAR(191) NULL,
`previousInsurer` VARCHAR(191) NULL,
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NOT NULL,
`userRole` TEXT NULL,
`customerId` INTEGER NULL,
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
`resourceType` VARCHAR(191) NOT NULL,
`resourceId` VARCHAR(191) NULL,
`resourceLabel` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NOT NULL,
`httpMethod` VARCHAR(191) NOT NULL,
`ipAddress` VARCHAR(191) NOT NULL,
`userAgent` TEXT NULL,
`changesBefore` LONGTEXT NULL,
`changesAfter` LONGTEXT NULL,
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
`dataSubjectId` INTEGER NULL,
`legalBasis` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL DEFAULT true,
`errorMessage` TEXT NULL,
`durationMs` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`hash` VARCHAR(191) NULL,
`previousHash` VARCHAR(191) NULL,
INDEX `AuditLog_userId_idx`(`userId`),
INDEX `AuditLog_customerId_idx`(`customerId`),
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
INDEX `AuditLog_action_idx`(`action`),
INDEX `AuditLog_createdAt_idx`(`createdAt`),
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerConsent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`version` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerConsent_customerId_idx`(`customerId`),
INDEX `CustomerConsent_consentType_idx`(`consentType`),
INDEX `CustomerConsent_status_idx`(`status`),
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `DataDeletionRequest` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`requestSource` VARCHAR(191) NOT NULL,
`requestedBy` VARCHAR(191) NOT NULL,
`processedAt` DATETIME(3) NULL,
`processedBy` VARCHAR(191) NULL,
`deletedData` LONGTEXT NULL,
`retainedData` LONGTEXT NULL,
`retentionReason` TEXT NULL,
`proofDocument` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
INDEX `DataDeletionRequest_status_idx`(`status`),
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditRetentionPolicy` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resourceType` VARCHAR(191) NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
`retentionDays` INTEGER NOT NULL,
`description` VARCHAR(191) NULL,
`legalBasis` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SecurityEvent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`type` ENUM('LOGIN_FAILED', 'LOGIN_SUCCESS', 'RATE_LIMIT_HIT', 'ACCESS_DENIED', 'SSRF_BLOCKED', 'PASSWORD_RESET_REQUEST', 'PASSWORD_RESET_CONFIRM', 'LOGOUT', 'TOKEN_REJECTED', 'PERMISSION_CHANGED', 'SUSPICIOUS') NOT NULL,
`severity` ENUM('INFO', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL,
`message` TEXT NOT NULL,
`ipAddress` VARCHAR(191) NULL,
`userId` INTEGER NULL,
`customerId` INTEGER NULL,
`userEmail` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NULL,
`details` JSON NULL,
`alerted` BOOLEAN NOT NULL DEFAULT false,
`alertedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `SecurityEvent_type_createdAt_idx`(`type`, `createdAt`),
INDEX `SecurityEvent_severity_createdAt_idx`(`severity`, `createdAt`),
INDEX `SecurityEvent_ipAddress_createdAt_idx`(`ipAddress`, `createdAt`),
INDEX `SecurityEvent_alerted_severity_idx`(`alerted`, `severity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RepresentativeAuthorization` ADD CONSTRAINT `RepresentativeAuthorization_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractDocument` ADD CONSTRAINT `ContractDocument_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractMeter` ADD CONSTRAINT `ContractMeter_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,354 +0,0 @@
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`customerId` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_customerId_key`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Role_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Permission` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resource` VARCHAR(191) NOT NULL,
`action` VARCHAR(191) NOT NULL,
UNIQUE INDEX `Permission_resource_action_key`(`resource`, `action`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`roleId` INTEGER NOT NULL,
`permissionId` INTEGER NOT NULL,
PRIMARY KEY (`roleId`, `permissionId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `UserRole` (
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,
PRIMARY KEY (`userId`, `roleId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Customer` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerNumber` VARCHAR(191) NOT NULL,
`type` ENUM('PRIVATE', 'BUSINESS') NOT NULL DEFAULT 'PRIVATE',
`salutation` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`companyName` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`mobile` VARCHAR(191) NULL,
`taxNumber` VARCHAR(191) NULL,
`businessRegistration` TEXT NULL,
`commercialRegister` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Customer_customerNumber_key`(`customerNumber`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Address` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('DELIVERY_RESIDENCE', 'BILLING') NOT NULL DEFAULT 'DELIVERY_RESIDENCE',
`street` VARCHAR(191) NOT NULL,
`houseNumber` VARCHAR(191) NOT NULL,
`postalCode` VARCHAR(191) NOT NULL,
`city` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'Deutschland',
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `BankCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`accountHolder` VARCHAR(191) NOT NULL,
`iban` VARCHAR(191) NOT NULL,
`bic` VARCHAR(191) NULL,
`bankName` VARCHAR(191) NULL,
`expiryDate` DATETIME(3) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `IdentityDocument` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`type` ENUM('ID_CARD', 'PASSPORT', 'DRIVERS_LICENSE', 'OTHER') NOT NULL DEFAULT 'ID_CARD',
`documentNumber` VARCHAR(191) NOT NULL,
`issuingAuthority` VARCHAR(191) NULL,
`issueDate` DATETIME(3) NULL,
`expiryDate` DATETIME(3) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Meter` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`meterNumber` VARCHAR(191) NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS') NOT NULL,
`location` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MeterReading` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`meterId` INTEGER NOT NULL,
`readingDate` DATETIME(3) NOT NULL,
`value` DOUBLE NOT NULL,
`unit` VARCHAR(191) NOT NULL DEFAULT 'kWh',
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `SalesPlatform` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`contactInfo` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `SalesPlatform_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Contract` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractNumber` VARCHAR(191) NOT NULL,
`customerId` INTEGER NOT NULL,
`type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL,
`status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED') NOT NULL DEFAULT 'DRAFT',
`addressId` INTEGER NULL,
`bankCardId` INTEGER NULL,
`identityDocumentId` INTEGER NULL,
`salesPlatformId` INTEGER NULL,
`previousContractId` INTEGER NULL,
`providerName` VARCHAR(191) NULL,
`tariffName` VARCHAR(191) NULL,
`customerNumberAtProvider` VARCHAR(191) NULL,
`startDate` DATETIME(3) NULL,
`endDate` DATETIME(3) NULL,
`cancellationPeriod` INTEGER NULL,
`commission` DOUBLE NULL,
`portalUsername` VARCHAR(191) NULL,
`portalPasswordEncrypted` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Contract_contractNumber_key`(`contractNumber`),
UNIQUE INDEX `Contract_previousContractId_key`(`previousContractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EnergyContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`meterId` INTEGER NULL,
`annualConsumption` DOUBLE NULL,
`basePrice` DOUBLE NULL,
`unitPrice` DOUBLE NULL,
`bonus` DOUBLE NULL,
`previousProviderName` VARCHAR(191) NULL,
`previousCustomerNumber` VARCHAR(191) NULL,
UNIQUE INDEX `EnergyContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `InternetContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`downloadSpeed` INTEGER NULL,
`uploadSpeed` INTEGER NULL,
`routerModel` VARCHAR(191) NULL,
`routerSerialNumber` VARCHAR(191) NULL,
`installationDate` DATETIME(3) NULL,
UNIQUE INDEX `InternetContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PhoneNumber` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`internetContractDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NOT NULL,
`isMain` BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `MobileContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`dataVolume` DOUBLE NULL,
`includedMinutes` INTEGER NULL,
`includedSMS` INTEGER NULL,
`deviceModel` VARCHAR(191) NULL,
`deviceImei` VARCHAR(191) NULL,
UNIQUE INDEX `MobileContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TvContractDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`receiverModel` VARCHAR(191) NULL,
`smartcardNumber` VARCHAR(191) NULL,
`package` VARCHAR(191) NULL,
UNIQUE INDEX `TvContractDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CarInsuranceDetails` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`licensePlate` VARCHAR(191) NULL,
`hsn` VARCHAR(191) NULL,
`tsn` VARCHAR(191) NULL,
`vin` VARCHAR(191) NULL,
`vehicleType` VARCHAR(191) NULL,
`firstRegistration` DATETIME(3) NULL,
`noClaimsClass` VARCHAR(191) NULL,
`insuranceType` ENUM('LIABILITY', 'PARTIAL', 'FULL') NOT NULL DEFAULT 'LIABILITY',
`deductiblePartial` DOUBLE NULL,
`deductibleFull` DOUBLE NULL,
`policyNumber` VARCHAR(191) NULL,
`previousInsurer` VARCHAR(191) NULL,
UNIQUE INDEX `CarInsuranceDetails_contractId_key`(`contractId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `User` ADD CONSTRAINT `User_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `Permission`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserRole` ADD CONSTRAINT `UserRole_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Address` ADD CONSTRAINT `Address_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BankCard` ADD CONSTRAINT `BankCard_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `IdentityDocument` ADD CONSTRAINT `IdentityDocument_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Meter` ADD CONSTRAINT `Meter_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MeterReading` ADD CONSTRAINT `MeterReading_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_bankCardId_fkey` FOREIGN KEY (`bankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_identityDocumentId_fkey` FOREIGN KEY (`identityDocumentId`) REFERENCES `IdentityDocument`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_salesPlatformId_fkey` FOREIGN KEY (`salesPlatformId`) REFERENCES `SalesPlatform`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousContractId_fkey` FOREIGN KEY (`previousContractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EnergyContractDetails` ADD CONSTRAINT `EnergyContractDetails_meterId_fkey` FOREIGN KEY (`meterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `InternetContractDetails` ADD CONSTRAINT `InternetContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PhoneNumber` ADD CONSTRAINT `PhoneNumber_internetContractDetailsId_fkey` FOREIGN KEY (`internetContractDetailsId`) REFERENCES `InternetContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MobileContractDetails` ADD CONSTRAINT `MobileContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TvContractDetails` ADD CONSTRAINT `TvContractDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CarInsuranceDetails` ADD CONSTRAINT `CarInsuranceDetails_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE `BankCard` ADD COLUMN `documentPath` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `IdentityDocument` ADD COLUMN `documentPath` VARCHAR(191) NULL;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `birthDate` DATETIME(3) NULL,
ADD COLUMN `birthPlace` VARCHAR(191) NULL;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE `IdentityDocument` ADD COLUMN `licenseClasses` VARCHAR(191) NULL,
ADD COLUMN `licenseIssueDate` DATETIME(3) NULL;
@@ -1,14 +0,0 @@
/*
Warnings:
- You are about to drop the column `businessRegistration` on the `Customer` table. All the data in the column will be lost.
- You are about to drop the column `commercialRegister` on the `Customer` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Customer` DROP COLUMN `businessRegistration`,
DROP COLUMN `commercialRegister`,
ADD COLUMN `businessRegistrationPath` VARCHAR(191) NULL,
ADD COLUMN `commercialRegisterNumber` VARCHAR(191) NULL,
ADD COLUMN `commercialRegisterPath` VARCHAR(191) NULL,
ADD COLUMN `foundingDate` DATETIME(3) NULL;
@@ -1,31 +0,0 @@
/*
Warnings:
- You are about to drop the column `cancellationPeriod` on the `Contract` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Contract` DROP COLUMN `cancellationPeriod`,
ADD COLUMN `cancellationPeriodId` INTEGER NULL,
ADD COLUMN `priceAfter24Months` VARCHAR(191) NULL,
ADD COLUMN `priceFirst12Months` VARCHAR(191) NULL,
ADD COLUMN `priceFrom13Months` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `privacyPolicyPath` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `CancellationPeriod` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CancellationPeriod_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_cancellationPeriodId_fkey` FOREIGN KEY (`cancellationPeriodId`) REFERENCES `CancellationPeriod`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,18 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractDurationId` INTEGER NULL;
-- CreateTable
CREATE TABLE `ContractDuration` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractDuration_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractDurationId_fkey` FOREIGN KEY (`contractDurationId`) REFERENCES `ContractDuration`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `cancellationConfirmationDate` DATETIME(3) NULL,
ADD COLUMN `cancellationConfirmationOptionsDate` DATETIME(3) NULL,
ADD COLUMN `cancellationConfirmationOptionsPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationConfirmationPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationLetterOptionsPath` VARCHAR(191) NULL,
ADD COLUMN `cancellationLetterPath` VARCHAR(191) NULL,
ADD COLUMN `wasSpecialCancellation` BOOLEAN NOT NULL DEFAULT false;
@@ -1,40 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `providerId` INTEGER NULL,
ADD COLUMN `tariffId` INTEGER NULL;
-- CreateTable
CREATE TABLE `Provider` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`portalUrl` VARCHAR(191) NULL,
`usernameFieldName` VARCHAR(191) NULL,
`passwordFieldName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Provider_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Tariff` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`providerId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Tariff_providerId_name_key`(`providerId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Tariff` ADD CONSTRAINT `Tariff_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_providerId_fkey` FOREIGN KEY (`providerId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,21 +0,0 @@
-- AlterTable
ALTER TABLE `MobileContractDetails` ADD COLUMN `requiresMultisim` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `SimCard` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`mobileDetailsId` INTEGER NOT NULL,
`phoneNumber` VARCHAR(191) NULL,
`simCardNumber` VARCHAR(191) NULL,
`pin` VARCHAR(191) NULL,
`puk` VARCHAR(191) NULL,
`isMultisim` BOOLEAN NOT NULL DEFAULT false,
`isMain` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `SimCard` ADD CONSTRAINT `SimCard_mobileDetailsId_fkey` FOREIGN KEY (`mobileDetailsId`) REFERENCES `MobileContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,21 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractCategoryId` INTEGER NULL;
-- CreateTable
CREATE TABLE `ContractCategory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`icon` VARCHAR(191) NULL,
`color` VARCHAR(191) NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ContractCategory_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_contractCategoryId_fkey` FOREIGN KEY (`contractCategoryId`) REFERENCES `ContractCategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,13 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` MODIFY `type` ENUM('ELECTRICITY', 'GAS', 'DSL', 'CABLE', 'FIBER', 'MOBILE', 'TV', 'CAR_INSURANCE') NOT NULL;
-- AlterTable
ALTER TABLE `InternetContractDetails` ADD COLUMN `activationCode` VARCHAR(191) NULL,
ADD COLUMN `homeId` VARCHAR(191) NULL,
ADD COLUMN `internetPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `internetUsername` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `PhoneNumber` ADD COLUMN `sipPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `sipServer` VARCHAR(191) NULL,
ADD COLUMN `sipUsername` VARCHAR(191) NULL;
@@ -1,180 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[portalEmail]` on the table `Customer` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `stressfreiEmailId` INTEGER NULL,
MODIFY `status` ENUM('DRAFT', 'PENDING', 'ACTIVE', 'CANCELLED', 'EXPIRED', 'DEACTIVATED') NOT NULL DEFAULT 'DRAFT';
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `portalEmail` VARCHAR(191) NULL,
ADD COLUMN `portalEnabled` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `portalLastLogin` DATETIME(3) NULL,
ADD COLUMN `portalPasswordEncrypted` VARCHAR(191) NULL,
ADD COLUMN `portalPasswordHash` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `AppSetting` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AppSetting_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerRepresentative` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`representativeId` INTEGER NOT NULL,
`notes` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerRepresentative_customerId_representativeId_key`(`customerId`, `representativeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `EmailProviderConfig` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`type` ENUM('PLESK', 'CPANEL', 'DIRECTADMIN') NOT NULL,
`apiUrl` VARCHAR(191) NOT NULL,
`apiKey` VARCHAR(191) NULL,
`username` VARCHAR(191) NULL,
`passwordEncrypted` VARCHAR(191) NULL,
`domain` VARCHAR(191) NOT NULL,
`defaultForwardEmail` VARCHAR(191) NULL,
`imapServer` VARCHAR(191) NULL,
`imapPort` INTEGER NULL DEFAULT 993,
`smtpServer` VARCHAR(191) NULL,
`smtpPort` INTEGER NULL DEFAULT 465,
`imapEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`smtpEncryption` ENUM('SSL', 'STARTTLS', 'NONE') NOT NULL DEFAULT 'SSL',
`allowSelfSignedCerts` BOOLEAN NOT NULL DEFAULT false,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isDefault` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `EmailProviderConfig_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `StressfreiEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`email` VARCHAR(191) NOT NULL,
`platform` VARCHAR(191) NULL,
`notes` TEXT NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`isProvisioned` BOOLEAN NOT NULL DEFAULT false,
`provisionedAt` DATETIME(3) NULL,
`provisionError` TEXT NULL,
`hasMailbox` BOOLEAN NOT NULL DEFAULT false,
`emailPasswordEncrypted` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CachedEmail` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`stressfreiEmailId` INTEGER NOT NULL,
`folder` ENUM('INBOX', 'SENT') NOT NULL DEFAULT 'INBOX',
`messageId` VARCHAR(191) NOT NULL,
`uid` INTEGER NOT NULL,
`subject` VARCHAR(191) NULL,
`fromAddress` VARCHAR(191) NOT NULL,
`fromName` VARCHAR(191) NULL,
`toAddresses` TEXT NOT NULL,
`ccAddresses` TEXT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`textBody` LONGTEXT NULL,
`htmlBody` LONGTEXT NULL,
`hasAttachments` BOOLEAN NOT NULL DEFAULT false,
`attachmentNames` TEXT NULL,
`contractId` INTEGER NULL,
`assignedAt` DATETIME(3) NULL,
`assignedBy` INTEGER NULL,
`isAutoAssigned` BOOLEAN NOT NULL DEFAULT false,
`isRead` BOOLEAN NOT NULL DEFAULT false,
`isStarred` BOOLEAN NOT NULL DEFAULT false,
`isDeleted` BOOLEAN NOT NULL DEFAULT false,
`deletedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CachedEmail_contractId_idx`(`contractId`),
INDEX `CachedEmail_stressfreiEmailId_folder_receivedAt_idx`(`stressfreiEmailId`, `folder`, `receivedAt`),
INDEX `CachedEmail_stressfreiEmailId_isDeleted_idx`(`stressfreiEmailId`, `isDeleted`),
UNIQUE INDEX `CachedEmail_stressfreiEmailId_messageId_folder_key`(`stressfreiEmailId`, `messageId`, `folder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`visibleInPortal` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ContractTaskSubtask` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`status` ENUM('OPEN', 'COMPLETED') NOT NULL DEFAULT 'OPEN',
`createdBy` VARCHAR(191) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `Customer_portalEmail_key` ON `Customer`(`portalEmail`);
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerRepresentative` ADD CONSTRAINT `CustomerRepresentative_representativeId_fkey` FOREIGN KEY (`representativeId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StressfreiEmail` ADD CONSTRAINT `StressfreiEmail_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CachedEmail` ADD CONSTRAINT `CachedEmail_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_stressfreiEmailId_fkey` FOREIGN KEY (`stressfreiEmailId`) REFERENCES `StressfreiEmail`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTask` ADD CONSTRAINT `ContractTask_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ContractTaskSubtask` ADD CONSTRAINT `ContractTaskSubtask_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `ContractTask`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `tokenInvalidatedAt` DATETIME(3) NULL;
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `billingAddressId` INTEGER NULL;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_billingAddressId_fkey` FOREIGN KEY (`billingAddressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `EnergyContractDetails` ADD COLUMN `annualConsumptionKwh` DOUBLE NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `EnergyContractDetails` ADD COLUMN `maloId` VARCHAR(191) NULL;
@@ -1,17 +0,0 @@
-- CreateTable
CREATE TABLE `Invoice` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`energyContractDetailsId` INTEGER NOT NULL,
`invoiceDate` DATETIME(3) NOT NULL,
`invoiceType` ENUM('INTERIM', 'FINAL', 'NOT_AVAILABLE') NOT NULL,
`documentPath` VARCHAR(191) NULL,
`notes` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Invoice_energyContractDetailsId_idx`(`energyContractDetailsId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_energyContractDetailsId_fkey` FOREIGN KEY (`energyContractDetailsId`) REFERENCES `EnergyContractDetails`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `nextReviewDate` DATETIME(3) NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `contractNumberAtProvider` VARCHAR(191) NULL;
@@ -1,7 +0,0 @@
-- AlterTable
ALTER TABLE `Contract` ADD COLUMN `previousContractNumber` VARCHAR(191) NULL,
ADD COLUMN `previousCustomerNumber` VARCHAR(191) NULL,
ADD COLUMN `previousProviderId` INTEGER NULL;
-- AddForeignKey
ALTER TABLE `Contract` ADD CONSTRAINT `Contract_previousProviderId_fkey` FOREIGN KEY (`previousProviderId`) REFERENCES `Provider`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE `ContractHistoryEntry` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`contractId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`isAutomatic` BOOLEAN NOT NULL DEFAULT false,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `ContractHistoryEntry` ADD CONSTRAINT `ContractHistoryEntry_contractId_fkey` FOREIGN KEY (`contractId`) REFERENCES `Contract`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,103 +0,0 @@
-- CreateTable
CREATE TABLE `AuditLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NOT NULL,
`userRole` VARCHAR(191) NULL,
`customerId` INTEGER NULL,
`isCustomerPortal` BOOLEAN NOT NULL DEFAULT false,
`action` ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'EXPORT', 'ANONYMIZE', 'LOGIN', 'LOGOUT', 'LOGIN_FAILED') NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NOT NULL DEFAULT 'MEDIUM',
`resourceType` VARCHAR(191) NOT NULL,
`resourceId` VARCHAR(191) NULL,
`resourceLabel` VARCHAR(191) NULL,
`endpoint` VARCHAR(191) NOT NULL,
`httpMethod` VARCHAR(191) NOT NULL,
`ipAddress` VARCHAR(191) NOT NULL,
`userAgent` TEXT NULL,
`changesBefore` LONGTEXT NULL,
`changesAfter` LONGTEXT NULL,
`changesEncrypted` BOOLEAN NOT NULL DEFAULT false,
`dataSubjectId` INTEGER NULL,
`legalBasis` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL DEFAULT true,
`errorMessage` TEXT NULL,
`durationMs` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`hash` VARCHAR(191) NULL,
`previousHash` VARCHAR(191) NULL,
INDEX `AuditLog_userId_idx`(`userId`),
INDEX `AuditLog_customerId_idx`(`customerId`),
INDEX `AuditLog_resourceType_resourceId_idx`(`resourceType`, `resourceId`),
INDEX `AuditLog_dataSubjectId_idx`(`dataSubjectId`),
INDEX `AuditLog_action_idx`(`action`),
INDEX `AuditLog_createdAt_idx`(`createdAt`),
INDEX `AuditLog_sensitivity_idx`(`sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CustomerConsent` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`consentType` ENUM('DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER') NOT NULL,
`status` ENUM('GRANTED', 'WITHDRAWN', 'PENDING') NOT NULL DEFAULT 'PENDING',
`grantedAt` DATETIME(3) NULL,
`withdrawnAt` DATETIME(3) NULL,
`source` VARCHAR(191) NULL,
`documentPath` VARCHAR(191) NULL,
`version` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdBy` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerConsent_customerId_idx`(`customerId`),
INDEX `CustomerConsent_consentType_idx`(`consentType`),
INDEX `CustomerConsent_status_idx`(`status`),
UNIQUE INDEX `CustomerConsent_customerId_consentType_key`(`customerId`, `consentType`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `DataDeletionRequest` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`customerId` INTEGER NOT NULL,
`status` ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'PARTIALLY_COMPLETED', 'REJECTED') NOT NULL DEFAULT 'PENDING',
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`requestSource` VARCHAR(191) NOT NULL,
`requestedBy` VARCHAR(191) NOT NULL,
`processedAt` DATETIME(3) NULL,
`processedBy` VARCHAR(191) NULL,
`deletedData` LONGTEXT NULL,
`retainedData` LONGTEXT NULL,
`retentionReason` TEXT NULL,
`proofDocument` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `DataDeletionRequest_customerId_idx`(`customerId`),
INDEX `DataDeletionRequest_status_idx`(`status`),
INDEX `DataDeletionRequest_requestedAt_idx`(`requestedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `AuditRetentionPolicy` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`resourceType` VARCHAR(191) NOT NULL,
`sensitivity` ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') NULL,
`retentionDays` INTEGER NOT NULL,
`description` VARCHAR(191) NULL,
`legalBasis` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `AuditRetentionPolicy_resourceType_sensitivity_key`(`resourceType`, `sensitivity`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `CustomerConsent` ADD CONSTRAINT `CustomerConsent_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `Customer`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,10 +0,0 @@
-- AlterTable
ALTER TABLE `Customer` ADD COLUMN `consentHash` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `User` ADD COLUMN `whatsappNumber` VARCHAR(191) NULL,
ADD COLUMN `telegramUsername` VARCHAR(191) NULL,
ADD COLUMN `signalNumber` VARCHAR(191) NULL;
-- CreateIndex
CREATE UNIQUE INDEX `Customer_consentHash_key` ON `Customer`(`consentHash`);
@@ -0,0 +1,5 @@
-- AlterTable
-- IF NOT EXISTS: macht das Hochziehen auf prod-DBs sicher, die das Feld
-- über `prisma db push` schon erhalten haben (vor dem Migrations-Workflow).
-- MariaDB unterstützt das seit 10.0.2, MySQL 8 ebenfalls.
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `portalPasswordMustChange` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,23 @@
-- BackupLog: persistierte Historie aller Backup-/Restore-Vorgänge mit
-- Status + Volltext-Log. UI zeigt in zwei Listen (je CREATE und RESTORE).
--
-- IF NOT EXISTS damit Re-Deploys auf bestehende DBs nicht crashen, falls
-- jemand vorher manuell `prisma db push` gefahren hat.
CREATE TABLE IF NOT EXISTS `BackupLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`operation` ENUM('CREATE', 'RESTORE') NOT NULL,
`backupName` VARCHAR(191) NULL,
`success` BOOLEAN NOT NULL,
`durationMs` INTEGER NOT NULL DEFAULT 0,
`summary` TEXT NOT NULL,
`fullLog` LONGTEXT NOT NULL,
`userId` INTEGER NULL,
`userEmail` VARCHAR(191) NULL,
`ipAddress` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `BackupLog_operation_createdAt_idx`(`operation`, `createdAt`),
INDEX `BackupLog_createdAt_idx`(`createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,30 @@
-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen.
-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei
-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus).
-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben.
--
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
-- `prisma db push` gefahren hat.
ALTER TABLE `EnergyContractDetails`
ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL,
ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL;
-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'EnergyContractDetails'
AND COLUMN_NAME = 'bonus'
);
SET @sql := IF(
@col_exists > 0,
'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL',
'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Alte Spalte droppen, falls vorhanden
ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`;
+94
View File
@@ -0,0 +1,94 @@
/**
* Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB.
* Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein
* Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte
* E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift).
*
* Aufruf:
* npx tsx prisma/reset-admin-password.ts <email> # generiert PW
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # eigenes PW
*
* Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions
* dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung
* gestohlener Tokens).
*/
import bcrypt from 'bcryptjs';
import prisma from '../src/lib/prisma.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js';
const BCRYPT_COST = 12;
function generateRescuePassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
async function main() {
const email = process.argv[2];
const providedPw = process.argv[3];
if (!email) {
console.error('Aufruf: npx tsx prisma/reset-admin-password.ts <email> [passwort]');
process.exit(1);
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, email: true, firstName: true, lastName: true },
});
if (!user) {
console.error(`User "${email}" nicht gefunden.`);
process.exit(2);
}
let plain: string;
if (providedPw) {
const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:');
for (const e of c.errors) console.error(' - ' + e);
process.exit(3);
}
plain = providedPw;
} else {
plain = generateRescuePassword();
}
const hash = await bcrypt.hash(plain, BCRYPT_COST);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
passwordResetToken: null,
passwordResetExpiresAt: null,
tokenInvalidatedAt: new Date(),
},
});
console.log('========================================================');
console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`);
console.log(` Neues Passwort: ${plain}`);
console.log(' ⚠️ Wird hier EINMAL ausgegeben sofort kopieren!');
console.log(' Alle bestehenden Sessions wurden invalidiert.');
console.log('========================================================');
}
main()
.catch((e) => {
console.error('Reset fehlgeschlagen:', e);
process.exit(99);
})
.finally(async () => {
await prisma.$disconnect();
});
+144 -373
View File
@@ -4,43 +4,40 @@
* Stellt Daten aus einem JSON-Backup wieder her.
*
* Verwendung:
* npx ts-node prisma/restore-data.ts [backup-ordner]
* npm run db:restore # Letztes Backup
* npx tsx prisma/restore-data.ts <ordner> # Bestimmtes Backup
*
* Beispiele:
* npx ts-node prisma/restore-data.ts # Letztes Backup
* npx ts-node prisma/restore-data.ts 2025-01-31_14-30-00 # Bestimmtes Backup
*
* WICHTIG: Führe vorher 'npx prisma migrate deploy' oder 'npx prisma db push' aus!
* WICHTIG: Führe vorher 'npx prisma db push' oder 'npx prisma migrate deploy' aus,
* damit das Schema zur DB passt!
*/
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
// Hilfsfunktion: JSON-Datei lesen
// Hilfsfunktion: JSON-Datei lesen (leer bei fehlender Datei)
function readJsonFile<T>(filePath: string): T[] {
if (!fs.existsSync(filePath)) {
return [];
}
if (!fs.existsSync(filePath)) return [];
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch {
return [];
}
}
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
// Hilfsfunktion: ISO-Datum-Strings rekursiv zu Date-Objekten
function convertDates(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
// ISO-Datumsformat erkennen
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
return new Date(obj);
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convertDates);
}
if (Array.isArray(obj)) return obj.map(convertDates);
if (typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
@@ -51,19 +48,75 @@ function convertDates(obj: any): any {
return obj;
}
/**
* Generischer Restore-Helper: nutzt createMany mit skipDuplicates
* wenn möglich, sonst einzelnes upsert per ID.
*/
async function restoreTable<T extends { id?: any }>(
tableName: string,
data: T[],
model: any,
options: { useCreateMany?: boolean; compositeKey?: string[] } = {},
): Promise<number> {
if (data.length === 0) return 0;
const converted = data.map(convertDates) as T[];
// Bei einfachen Tabellen: createMany mit skipDuplicates
if (options.useCreateMany) {
try {
const result = await model.createMany({
data: converted,
skipDuplicates: true,
});
return result.count;
} catch (err: any) {
// Fallback auf einzeln
}
}
// Upsert per ID (oder Composite-Key)
let count = 0;
for (const item of converted) {
try {
if (options.compositeKey) {
const where: any = {};
const compositeWhere: any = {};
for (const key of options.compositeKey) {
compositeWhere[key] = (item as any)[key];
}
where[options.compositeKey.join('_')] = compositeWhere;
await model.upsert({
where,
update: {},
create: item,
});
} else {
await model.upsert({
where: { id: (item as any).id },
update: item,
create: item,
});
}
count++;
} catch (err: any) {
console.log(` ⚠️ Eintrag in ${tableName} (id=${(item as any).id}): ${err.message?.slice(0, 80)}`);
}
}
return count;
}
async function main() {
// Backup-Ordner bestimmen
const backupsDir = path.join(__dirname, 'backups');
let backupName = process.argv[2];
if (!backupName) {
// Neuestes Backup finden
if (!fs.existsSync(backupsDir)) {
console.error('❌ Kein Backup-Ordner gefunden!');
process.exit(1);
}
const backups = fs.readdirSync(backupsDir)
.filter(f => fs.statSync(path.join(backupsDir, f)).isDirectory())
.filter((f) => fs.statSync(path.join(backupsDir, f)).isDirectory())
.sort()
.reverse();
@@ -76,18 +129,16 @@ async function main() {
}
const backupDir = path.join(backupsDir, backupName);
if (!fs.existsSync(backupDir)) {
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
process.exit(1);
}
// Backup-Info lesen
const infoPath = path.join(backupDir, '_backup-info.json');
if (fs.existsSync(infoPath)) {
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
console.log(`\n📅 Backup vom: ${new Date(info.timestamp).toLocaleString('de-DE')}`);
console.log(`📊 ${info.totalRecords} Datensätze in ${info.tables.filter((t: any) => t.count > 0).length} Tabellen\n`);
console.log(`📊 ${info.totalRecords} Datensätze\n`);
}
console.log(`🔄 Starte Wiederherstellung aus: ${backupDir}\n`);
@@ -96,362 +147,76 @@ async function main() {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
try {
// Tabellen in Abhängigkeitsreihenfolge wiederherstellen
const restoreOrder = [
// Level 0: Keine Abhängigkeiten
{
name: 'Permission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.permission.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Role',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.role.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SalesPlatform',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.salesPlatform.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractCategory',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractCategory.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CancellationPeriod',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cancellationPeriod.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDuration',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDuration.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AppSetting',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.appSetting.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailProviderConfig',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailProviderConfig.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EnergyProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Tabellen in Abhängigkeitsreihenfolge (gleich wie im Backup)
const order: Array<{
name: string;
model: any;
compositeKey?: string[];
}> = [
// Level 0
{ name: 'Permission', model: prisma.permission },
{ name: 'Role', model: prisma.role },
{ name: 'SalesPlatform', model: prisma.salesPlatform },
{ name: 'ContractCategory', model: prisma.contractCategory },
{ name: 'CancellationPeriod', model: prisma.cancellationPeriod },
{ name: 'ContractDuration', model: prisma.contractDuration },
{ name: 'AppSetting', model: prisma.appSetting },
{ name: 'EmailProviderConfig', model: prisma.emailProviderConfig },
{ name: 'Provider', model: prisma.provider },
{ name: 'PdfTemplate', model: prisma.pdfTemplate },
{ name: 'AuditRetentionPolicy', model: prisma.auditRetentionPolicy },
// Level 1
{
name: 'RolePermission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'User',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.user.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Customer',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customer.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Tariff',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.tariff.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{ name: 'RolePermission', model: prisma.rolePermission, compositeKey: ['roleId', 'permissionId'] },
{ name: 'User', model: prisma.user },
{ name: 'Customer', model: prisma.customer },
{ name: 'Tariff', model: prisma.tariff },
// Level 2
{
name: 'UserRole',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'CustomerRepresentative',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerRepresentative.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'StressfreiEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.stressfreiEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Contract',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contract.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Meter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 2: Customer-abhängig
{ name: 'UserRole', model: prisma.userRole, compositeKey: ['userId', 'roleId'] },
{ name: 'Address', model: prisma.address },
{ name: 'BankCard', model: prisma.bankCard },
{ name: 'IdentityDocument', model: prisma.identityDocument },
{ name: 'Meter', model: prisma.meter },
{ name: 'StressfreiEmail', model: prisma.stressfreiEmail },
{ name: 'CustomerRepresentative', model: prisma.customerRepresentative },
{ name: 'CustomerConsent', model: prisma.customerConsent },
{ name: 'DataDeletionRequest', model: prisma.dataDeletionRequest },
// Level 3
{
name: 'CachedEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cachedEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractTask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'MeterReading',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meterReading.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractNote',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractNote.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 3: Contracts
{ name: 'Contract', model: prisma.contract },
{ name: 'RepresentativeAuthorization', model: prisma.representativeAuthorization },
// Level 4
{
name: 'ContractTaskSubtask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTaskSubtask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 4: Vertragstyp-Details
{ name: 'EnergyContractDetails', model: prisma.energyContractDetails },
{ name: 'InternetContractDetails', model: prisma.internetContractDetails },
{ name: 'MobileContractDetails', model: prisma.mobileContractDetails },
{ name: 'TvContractDetails', model: prisma.tvContractDetails },
{ name: 'CarInsuranceDetails', model: prisma.carInsuranceDetails },
{ name: 'ContractMeter', model: prisma.contractMeter },
{ name: 'ContractDocument', model: prisma.contractDocument },
{ name: 'ContractHistoryEntry', model: prisma.contractHistoryEntry },
{ name: 'ContractTask', model: prisma.contractTask },
{ name: 'Invoice', model: prisma.invoice },
{ name: 'MeterReading', model: prisma.meterReading },
// Vertragsdetails
{
name: 'EnergyContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CarInsuranceDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.carInsuranceDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 5: Sub-Tabellen
{ name: 'ContractTaskSubtask', model: prisma.contractTaskSubtask },
{ name: 'PhoneNumber', model: prisma.phoneNumber },
{ name: 'SimCard', model: prisma.simCard },
// Level 6: Logs & Emails
{ name: 'CachedEmail', model: prisma.cachedEmail },
{ name: 'EmailLog', model: prisma.emailLog },
{ name: 'AuditLog', model: prisma.auditLog },
];
let totalRestored = 0;
const skipped: string[] = [];
for (const table of restoreOrder) {
for (const table of order) {
const filePath = path.join(backupDir, `${table.name}.json`);
const data = readJsonFile(filePath);
const data = readJsonFile<any>(filePath);
if (data.length === 0) {
console.log(`${table.name}: Keine Daten`);
@@ -459,17 +224,23 @@ async function main() {
}
try {
await table.restore(data);
totalRestored += data.length;
console.log(`${table.name}: ${data.length} Einträge wiederhergestellt`);
const count = await restoreTable(table.name, data, table.model, {
compositeKey: table.compositeKey,
});
totalRestored += count;
console.log(`${table.name}: ${count}/${data.length} Einträge wiederhergestellt`);
} catch (error: any) {
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 80)}`);
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 100)}`);
}
}
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt\n`);
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen mit Fehlern: ${skipped.join(', ')}`);
}
console.log('');
} finally {
// Foreign Key Checks wieder aktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
+110 -1
View File
@@ -78,6 +78,10 @@ model User {
isActive Boolean @default(true)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Messaging-Kanäle (für Datenschutz-Link-Versand)
whatsappNumber String?
telegramUsername String?
@@ -163,9 +167,26 @@ model Customer {
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
portalLastLogin DateTime? // Letzte Anmeldung
// Portal Passwort-Reset
portalPasswordResetToken String? @unique
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
// Frontend in Force-Change-Password-Flow geleitet.
portalPasswordMustChange Boolean @default(false)
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
// Anrede-Verhältnis: true = Du (informell), false = Sie (formell, Default)
useInformalAddress Boolean @default(false)
// Automatischer Geburtstagsgruß-Versand
autoBirthdayGreeting Boolean @default(false)
autoBirthdayChannel String? // "email", "whatsapp", "telegram", "signal"
user User?
addresses Address[]
bankCards BankCard[]
@@ -351,6 +372,10 @@ model EmailProviderConfig {
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
customerEmailLabel String?
isActive Boolean @default(true)
isDefault Boolean @default(false) // Standard-Provider
createdAt DateTime @default(now())
@@ -780,7 +805,14 @@ model EnergyContractDetails {
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
bonus Float?
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
// dargestellt. Migration kopiert das alte `bonus` nach
// `instantBonus` (Annahme: meistgenutzte Variante).
instantBonus Float? // Sofort-Bonus
newCustomerBonus Float? // Neukunden-Bonus
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
@@ -1092,3 +1124,80 @@ model AuditRetentionPolicy {
@@unique([resourceType, sensitivity])
}
// ==================== SECURITY MONITORING ====================
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
// effizient querybar). Threshold-Detection läuft per Cron.
enum SecurityEventType {
LOGIN_FAILED // falsches Passwort / unbekannter User
LOGIN_SUCCESS // erfolgreicher Login (informativ)
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
SUSPICIOUS // generischer Catch-All
}
enum SecuritySeverity {
INFO // Login-Success, Logout
LOW // Einzelner failed Login, einzelner 403
MEDIUM // Rate-Limit-Hit, mehrere 403er
HIGH // SSRF-Block, JWT-Manipulation
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
enum BackupOperation {
CREATE
RESTORE
}
// Persistiertes Log für Backup-Vorgänge.
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
// geschrieben.
model BackupLog {
id Int @id @default(autoincrement())
operation BackupOperation
backupName String?
success Boolean
durationMs Int @default(0)
summary String @db.Text
fullLog String @db.LongText
userId Int?
userEmail String?
ipAddress String?
createdAt DateTime @default(now())
@@index([operation, createdAt])
@@index([createdAt])
}
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
severity SecuritySeverity
message String @db.Text
ipAddress String?
userId Int? // Mitarbeiter (falls eingeloggt)
customerId Int? // Portal-Kunde (falls eingeloggt)
userEmail String? // beste Schätzung auch bei nicht eingeloggt
endpoint String? // betroffener Endpoint
details Json? // strukturierte Zusatzinfo
alerted Boolean @default(false) // schon per Email versendet?
alertedAt DateTime?
createdAt DateTime @default(now())
@@index([type, createdAt])
@@index([severity, createdAt])
@@index([ipAddress, createdAt])
@@index([alerted, severity])
}
+48 -3
View File
@@ -221,8 +221,41 @@ async function main() {
console.log('Roles created');
// Create admin user
const hashedPassword = await bcrypt.hash('admin', 10);
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
// Komplexitätspolicy). Stattdessen:
// - SEED_ADMIN_PASSWORD-ENV → wird verwendet (z.B. via docker-compose env)
// - sonst → zufälliges 16-Zeichen-Passwort, wird ein einziges Mal beim
// Seed in stdout ausgegeben. Wer das Log nicht sieht, muss
// Passwort-vergessen-Flow nutzen.
// Hash-Cost: 12 (OWASP 2026), nicht mehr 10.
function generateInitialPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghijkmnopqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*+=?';
const all = upper + lower + digits + special;
// Kryptografisch sichere Auswahl Math.random() ist vorhersagbar
// und reicht für ein Initial-Admin-Passwort nicht (Pentest 2026-05-20).
const pick = (s: string) => s[crypto.randomInt(0, s.length)];
// mind. einen aus jeder Klasse + Rest zufällig
const chars = [pick(upper), pick(lower), pick(digits), pick(special)];
// 28 Zeichen → Komplexität + komfortable Marge über dem 25-Zeichen-
// Mitarbeiter-Schwellwert (Pentest Runde 13).
for (let i = chars.length; i < 28; i++) chars.push(pick(all));
// Fisher-Yates Shuffle mit kryptografisch starkem Random.
for (let i = chars.length - 1; i > 0; i--) {
const j = crypto.randomInt(0, i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
const envPassword = process.env.SEED_ADMIN_PASSWORD;
const adminPlainPassword = envPassword && envPassword.length >= 25
? envPassword
: generateInitialPassword();
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.upsert({
where: { email: 'admin@admin.com' },
@@ -238,7 +271,19 @@ async function main() {
},
});
console.log('Admin user created: admin@admin.com / admin');
console.log('========================================================');
console.log(' Admin-User: admin@admin.com');
if (envPassword && envPassword.length >= 25) {
console.log(' Passwort: aus SEED_ADMIN_PASSWORD');
} else {
if (envPassword && envPassword.length < 25) {
console.log(' ⚠️ SEED_ADMIN_PASSWORD < 25 Zeichen, wird ignoriert!');
}
console.log(` Initial-Passwort: ${adminPlainPassword}`);
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
console.log(' Bitte sofort nach dem ersten Login ändern.');
}
console.log('========================================================');
// Create some sales platforms
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
+162
View File
@@ -0,0 +1,162 @@
/**
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
*
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
customers: ['create', 'read', 'update', 'delete'],
contracts: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
platforms: ['create', 'read', 'update', 'delete'],
providers: ['create', 'read', 'update', 'delete'],
tariffs: ['create', 'read', 'update', 'delete'],
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
developer: ['access'],
emails: ['delete'],
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
const existing = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const existingIds = new Set(existing.map((e) => e.permissionId));
const targetIds = new Set(permissionIds);
const missing = permissionIds.filter((id) => !existingIds.has(id));
if (missing.length > 0) {
await prisma.rolePermission.createMany({
data: missing.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
}
const excess = existing
.filter((e) => !targetIds.has(e.permissionId))
.map((e) => e.permissionId);
if (excess.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: excess } },
});
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
}
}
async function main() {
console.log('[sync-roles] Permissions-Katalog upserten…');
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
for (const action of actions) {
await prisma.permission.upsert({
where: { resource_action: { resource, action } },
update: {},
create: { resource, action },
});
}
}
const allPermissions = await prisma.permission.findMany();
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
// sind separate hidden roles, über Checkboxen zugewiesen)
const adminPermIds = allPermissions
.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
)
.map((p) => p.id);
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// DSGVO: audit + gdpr komplett
const gdprPermIds = allPermissions
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' &&
[
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
const readOnlyResources = [
'customers',
'contracts',
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
];
const readOnlyPermIds = allPermissions
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
.map((p) => p.id);
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
for (const r of rolesSpec) {
const role = await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: { name: r.name, description: r.description },
});
await syncRolePermissions(role.id, r.permIds);
}
console.log('[sync-roles] fertig.');
}
main()
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+368
View File
@@ -0,0 +1,368 @@
/**
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
*
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
* - Merged mehrere Dateien pro Kategorie automatisch
* - Nutzt Prisma upsert → idempotent, kann mehrfach aufgerufen werden
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
*
* Aufruf:
* npm run seed:defaults
*/
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ROOT kann via FACTORY_DEFAULTS_DIR überschrieben werden (Container-Bootstrap
// mit eingebauten Defaults aus dem Image).
const ROOT = process.env.FACTORY_DEFAULTS_DIR
? path.resolve(process.env.FACTORY_DEFAULTS_DIR)
: path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
interface ProviderDef {
name: string;
portalUrl?: string | null;
usernameFieldName?: string | null;
passwordFieldName?: string | null;
isActive?: boolean;
tariffs?: { name: string; isActive?: boolean }[];
}
interface CancellationPeriodDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractDurationDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractCategoryDef {
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}
interface PdfTemplateDef {
name: string;
description?: string | null;
providerName?: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix?: string | null;
maxPhoneFields?: number | null;
isActive?: boolean;
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
}
interface AppSettingDef {
key: string;
value: string;
}
// Whitelist muss synchron zu factoryDefaults.service.ts sein.
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
'privacyPolicyHtml',
'authorizationTemplateHtml',
'imprintHtml',
'websitePrivacyPolicyHtml',
]);
/**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/
function readJsonArrays<T>(dir: string): T[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const result: T[] = [];
for (const f of files) {
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
result.push(...data);
}
} catch (e) {
console.warn(`⚠ Konnte ${f} nicht parsen überspringe.`);
}
}
return result;
}
/**
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
*/
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return Array.from(map.values());
}
async function seedProviders() {
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
if (items.length === 0) {
console.log(' providers/ keine Einträge gefunden');
return;
}
let providerCount = 0;
let tariffCount = 0;
for (const p of items) {
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
create: {
name: p.name,
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
});
providerCount++;
if (p.tariffs && p.tariffs.length > 0) {
for (const t of p.tariffs) {
await prisma.tariff.upsert({
where: { providerId_name: { providerId: provider.id, name: t.name } },
update: { isActive: t.isActive ?? true },
create: {
providerId: provider.id,
name: t.name,
isActive: t.isActive ?? true,
},
});
tariffCount++;
}
}
}
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
}
async function seedCancellationPeriods() {
const items = dedupe(
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' cancellation-periods keine Einträge');
return;
}
for (const c of relevant) {
await prisma.cancellationPeriod.upsert({
where: { code: c.code },
update: { description: c.description, isActive: c.isActive ?? true },
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
});
}
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
}
async function seedContractDurations() {
const items = dedupe(
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' contract-durations keine Einträge');
return;
}
for (const d of relevant) {
await prisma.contractDuration.upsert({
where: { code: d.code },
update: { description: d.description, isActive: d.isActive ?? true },
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
});
}
console.log(` ✓ Laufzeiten: ${relevant.length}`);
}
async function seedContractCategories() {
const items = dedupe(
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
(i) => i.code,
);
if (items.length === 0) {
console.log(' contract-categories keine Einträge');
return;
}
for (const c of items) {
await prisma.contractCategory.upsert({
where: { code: c.code },
update: {
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
create: {
code: c.code,
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
});
}
console.log(` ✓ Vertragskategorien: ${items.length}`);
}
async function seedPdfTemplates() {
const items = dedupe(
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
(t) => t.name,
);
if (items.length === 0) {
console.log(' pdf-templates keine Einträge');
return;
}
// Upload-Verzeichnis sicherstellen
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
}
let count = 0;
let skipped = 0;
for (const t of items) {
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
if (!fs.existsSync(srcPdf)) {
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} Template "${t.name}" übersprungen`);
skipped++;
continue;
}
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.copyFileSync(srcPdf, destPdf);
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
if (existing?.templatePath) {
const oldRel = existing.templatePath.startsWith('/uploads/')
? existing.templatePath.substring('/uploads/'.length)
: existing.templatePath;
const oldAbs = path.join(UPLOADS_ROOT, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
await prisma.pdfTemplate.upsert({
where: { name: t.name },
update: {
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
create: {
name: t.name,
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
});
count++;
}
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function seedAppSettings() {
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
if (items.length === 0) {
console.log(' app-settings keine Einträge');
return;
}
let count = 0;
let skipped = 0;
for (const s of items) {
if (!s.key || typeof s.value !== 'string') continue;
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist übersprungen`);
skipped++;
continue;
}
await prisma.appSetting.upsert({
where: { key: s.key },
update: { value: s.value },
create: { key: s.key, value: s.value },
});
count++;
}
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
if (!fs.existsSync(ROOT)) {
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
process.exit(1);
}
await seedProviders();
await seedCancellationPeriods();
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
await seedAppSettings();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
main()
.catch((e) => {
console.error('\n❌ Fehler beim Einspielen:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -41,10 +41,22 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
return;
}
// Whitelist-Check (Pentest Runde 11, M1)
if (!appSettingService.isAllowedSettingKey(key)) {
res.status(400).json({
success: false,
error: `Unbekannter Setting-Key: ${key}`,
} as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
// sonst ungefiltert in E-Mail-Templates / PDFs.
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
await appSettingService.setSetting(key, newValue);
@@ -78,12 +90,24 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
return;
}
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
const unknownKeys = Object.keys(settings).filter(
(k) => !appSettingService.isAllowedSettingKey(k),
);
if (unknownKeys.length > 0) {
res.status(400).json({
success: false,
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
} as ApiResponse);
return;
}
// Vorherige Werte laden für Audit
const changes: Record<string, { von: unknown; nach: unknown }> = {};
for (const [key, value] of Object.entries(settings)) {
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
if (oldValue !== newValue) {
changes[key] = { von: oldValue, nach: newValue };
}
+372 -9
View File
@@ -1,12 +1,57 @@
import { Request, Response } from 'express';
import { Request, Response, CookieOptions } from 'express';
import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH, PORTAL_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Refresh-Token-Cookie-Konfiguration. Der Cookie:
// - httpOnly → kein JavaScript-Zugriff → bei XSS nicht klaubar
// - secure → nur über HTTPS (in Prod via HTTPS_ENABLED, in Dev egal)
// - sameSite 'strict' → CSRF-Schutz; Cross-Site-Requests senden den Cookie nicht
// - path '/api/auth' → wird nur an Auth-Endpoints mitgeschickt
const REFRESH_COOKIE_NAME = 'refresh_token';
function getRefreshCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: process.env.HTTPS_ENABLED === 'true',
sameSite: 'strict',
path: '/api/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage, gleicht Refresh-JWT-Lifetime
};
}
function setRefreshCookie(res: Response, token: string): void {
res.cookie(REFRESH_COOKIE_NAME, token, getRefreshCookieOptions());
}
function clearRefreshCookie(res: Response): void {
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
}
// Whitelist von Fehlermeldungen, die wir an Login-Clients durchreichen dürfen.
// ALLES andere (Prisma-Internals, DB-Connection-Errors, Schema-Fehler, ...)
// wird als generisches "Anmeldung fehlgeschlagen" maskiert die Original-
// Message bleibt im Server-Log, leakt aber nicht im HTTP-Response. Pentest
// Runde 3 (2026-05-16): `prisma.customer.findUnique() invocation: The column
// X does not exist` war im Body sichtbar → Tabellen-/Spaltennamen geleakt.
const SAFE_LOGIN_ERRORS = new Set([
'Ungültige Anmeldedaten',
'E-Mail und Passwort erforderlich',
]);
function safeLoginError(err: unknown): string {
if (err instanceof Error && SAFE_LOGIN_ERRORS.has(err.message)) {
return err.message;
}
if (err instanceof Error) {
console.error('[Login] Unerwarteter Fehler (maskiert):', err.message);
}
return 'Anmeldung fehlgeschlagen';
}
// Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -16,20 +61,43 @@ export async function login(req: Request, res: Response): Promise<void> {
}
const result = await authService.login(email, password);
res.json({ success: true, data: result } as ApiResponse);
// Refresh-Token in httpOnly-Cookie, Access-Token im Body (Frontend hält
// ihn nur in memory). `token`-Feld bleibt aus Kompatibilität bestehen.
setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Mitarbeiter-Login: ${email}`,
ipAddress: ctx.ipAddress,
userId: result.user.id,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
// Kundenportal-Login
export async function customerLogin(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -39,11 +107,32 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
}
const result = await authService.customerLogin(email, password);
res.json({ success: true, data: result } as ApiResponse);
setRefreshCookie(res, result.refreshToken);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Portal-Login: ${email}`,
ipAddress: ctx.ipAddress,
customerId: result.user.customerId,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
error: safeLoginError(error),
} as ApiResponse);
}
}
@@ -99,6 +188,187 @@ export async function me(req: AuthRequest, res: Response): Promise<void> {
}
}
/**
* Passwort-Reset anfordern (Email + Token per Mail).
* Immer 200 OK zurückgeben um Email-Existenz nicht preiszugeben (User-Enumeration-Schutz).
*/
export async function requestPasswordReset(req: Request, res: Response): Promise<void> {
try {
const { email, userType } = req.body; // userType: 'admin' | 'portal'
if (!email) {
res.status(400).json({ success: false, error: 'E-Mail erforderlich' } as ApiResponse);
return;
}
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_REQUEST',
severity: 'MEDIUM',
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
});
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
res.json({
success: true,
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
} as ApiResponse);
} catch (error) {
console.error('Password reset request error:', error);
// Auch bei Fehlern dieselbe Antwort
res.json({
success: true,
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
} as ApiResponse);
}
}
/**
* Passwort-Reset bestätigen (Token + neues Passwort).
*/
export async function confirmPasswordReset(req: Request, res: Response): Promise<void> {
try {
const { token, password } = req.body;
if (!token || !password) {
res.status(400).json({
success: false,
error: 'Token und neues Passwort erforderlich',
} as ApiResponse);
return;
}
// Audience anhand des Tokens bestimmen, damit Admin-Reset 25 Zeichen
// verlangt und Portal-Customer-Reset weiterhin 12 reicht.
const audience = await authService.getPasswordResetAudience(token);
const minLength = audience === 'admin' ? STAFF_MIN_PASSWORD_LENGTH : PORTAL_MIN_PASSWORD_LENGTH;
const complexity = validatePasswordComplexity(password, { minLength });
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
await authService.confirmPasswordReset(token, password);
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_CONFIRM',
severity: 'HIGH',
message: 'Passwort-Reset abgeschlossen',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.json({
success: true,
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
} as ApiResponse);
} catch (error) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: 'MEDIUM',
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
} as ApiResponse);
}
}
/**
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
*
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
* (auch andere Geräte) akzeptabel für ein Sicherheits-Logout.
*/
export async function logout(req: AuthRequest, res: Response): Promise<void> {
try {
const user = req.user as any;
if (!user) {
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
return;
}
if (user.isCustomerPortal && user.customerId) {
await prisma.customer.update({
where: { id: user.customerId },
data: { portalTokenInvalidatedAt: new Date() },
});
} else if (user.userId) {
await prisma.user.update({
where: { id: user.userId },
data: { tokenInvalidatedAt: new Date() },
});
}
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
// aber UI würde sich verirren).
clearRefreshCookie(res);
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'LOGOUT',
severity: 'INFO',
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
customerId: ctx.customerId,
userEmail: user.email,
endpoint: ctx.endpoint,
});
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Abmelden',
} as ApiResponse);
}
}
// Neuen Access-Token aus dem httpOnly-Refresh-Cookie holen. Wird vom Frontend
// (axios-Interceptor) bei 401 oder beim App-Start aufgerufen.
export async function refresh(req: Request, res: Response): Promise<void> {
try {
const cookies = (req as any).cookies || {};
const refreshToken = cookies[REFRESH_COOKIE_NAME];
if (!refreshToken) {
res.status(401).json({ success: false, error: 'Kein Refresh-Token vorhanden' } as ApiResponse);
return;
}
const result = await authService.refreshAccessToken(refreshToken);
// Refresh-Cookie rotieren verhindert Replay eines geklauten Refresh-Tokens
// bis zur vollen Lifetime.
setRefreshCookie(res, result.refreshToken);
res.json({
success: true,
data: { token: result.accessToken, user: result.user },
} as ApiResponse);
} catch (error) {
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
clearRefreshCookie(res);
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
} as ApiResponse);
}
}
export async function register(req: Request, res: Response): Promise<void> {
try {
const { email, password, firstName, lastName, roleIds } = req.body;
@@ -111,6 +381,16 @@ export async function register(req: Request, res: Response): Promise<void> {
return;
}
// Mitarbeiter-Anlage: 25-Zeichen-Schwellwert
const complexity = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
const user = await authService.createUser({
email,
password,
@@ -130,3 +410,86 @@ export async function register(req: Request, res: Response): Promise<void> {
} as ApiResponse);
}
}
// Kurzlebiger Download-Token (60s) für Aufrufe, die den Token in der URL
// brauchen (PDF-iframes, window.open für Audit-Export usw.). Aufrufer
// authentifiziert sich normal per Bearer-Header. Antwort: ein download-
// scoped JWT, das die Auth-Middleware nur via `?token=` akzeptiert.
export async function createDownloadToken(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
return;
}
const payload: any = {
email: req.user.email,
permissions: req.user.permissions,
isCustomerPortal: !!req.user.isCustomerPortal,
};
if (req.user.userId) payload.userId = req.user.userId;
if (req.user.customerId) payload.customerId = req.user.customerId;
if ((req.user as any).representedCustomerIds) {
payload.representedCustomerIds = (req.user as any).representedCustomerIds;
}
const token = authService.signDownloadToken(payload);
res.json({ success: true, data: { token } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Erstellen des Download-Tokens',
} as ApiResponse);
}
}
// Vom Endkunden selbst nach Einmalpasswort-Login aufgerufen, um sein eigenes
// Passwort zu vergeben. Server invalidiert die laufende Session, Frontend
// loggt aus und schickt zurück zum Login.
export async function changeInitialPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.isCustomerPortal || !req.user?.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Login',
} as ApiResponse);
return;
}
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) KRITISCH.
const customer = await prisma.customer.findUnique({
where: { id: req.user.customerId },
select: { portalPasswordMustChange: true },
});
if (!customer?.portalPasswordMustChange) {
res.status(403).json({
success: false,
error: 'Nicht erlaubt',
} as ApiResponse);
return;
}
const { newPassword } = req.body || {};
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({
success: false,
error: 'Neues Passwort erforderlich',
} as ApiResponse);
return;
}
const complexity = validatePasswordComplexity(newPassword);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
await authService.changeInitialPortalPassword(req.user.customerId, newPassword);
clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
}
}
+268 -10
View File
@@ -1,7 +1,94 @@
import { Request, Response } from 'express';
import * as backupService from '../services/backup.service.js';
import prisma from '../lib/prisma.js';
/**
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
* (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix).
* Blockt Path-Traversal-Versuche wie "../../etc/passwd".
*/
function isValidBackupName(name: string): boolean {
return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..');
}
import { logChange } from '../services/audit.service.js';
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
// process-globale Patch-Variante.
function startLogCapture(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
function fmt(args: unknown[]): string {
return args
.map((a) => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
})
.join(' ');
}
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
return {
lines,
restore: () => {
console.log = orig.log;
console.info = orig.info;
console.warn = orig.warn;
console.error = orig.error;
},
};
}
async function recordBackupLog(opts: {
req: Request;
operation: 'CREATE' | 'RESTORE';
backupName: string | null;
success: boolean;
durationMs: number;
summary: string;
fullLog: string;
}) {
try {
const user = (opts.req as any).user;
await prisma.backupLog.create({
data: {
operation: opts.operation,
backupName: opts.backupName,
success: opts.success,
durationMs: opts.durationMs,
summary: opts.summary.slice(0, 2000),
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
fullLog: opts.fullLog.slice(0, 1_000_000),
userId: user?.userId ?? null,
userEmail: user?.email ?? null,
ipAddress:
(opts.req as any).socket?.remoteAddress ||
(opts.req.headers?.['x-forwarded-for'] as string) ||
null,
},
});
} catch (err) {
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
}
}
/**
* Liste aller Backups abrufen
* GET /api/settings/backups
@@ -20,19 +107,44 @@ export async function listBackups(req: Request, res: Response) {
* POST /api/settings/backup
*/
export async function createBackup(req: Request, res: Response) {
const start = Date.now();
const capture = startLogCapture();
try {
const result = await backupService.createBackup();
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'CREATE', backupName: result.backupName ?? null,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'CREATE', resourceType: 'Backup',
label: `Backup ${result.backupName} erstellt`,
});
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
} else {
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
}
} catch (error: any) {
const durationMs = Date.now() - start;
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Fehler: ${error?.message || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
}
}
@@ -41,17 +153,63 @@ export async function createBackup(req: Request, res: Response) {
* Backup wiederherstellen
* POST /api/settings/backup/:name/restore
*/
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
// "Cannot read properties of undefined" → "Interner Code-Fehler".
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
function makeRestoreErrorReadable(raw: unknown): string {
if (!raw) return 'Unbekannter Fehler';
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
// Stack-Frames " at …(…:123:45)" abschneiden
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
// alles auf "Operation fehlgeschlagen" maskiert.
s = s
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
.replace(/is not defined$/i, '(Wert nicht definiert)')
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
return s.slice(0, 500); // Längenlimit für UI
}
export async function restoreBackup(req: Request, res: Response) {
try {
const start = Date.now();
const { name } = req.params;
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
// sofort den destruktiven Restore aus ein versehentlicher
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
// Klick) konnte die DB ungewollt überschreiben. Der String ist
// bewusst ein unique Magic-Value, kein Boolean.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'RESTORE-BESTAETIGT') {
return res.status(400).json({
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
});
}
const capture = startLogCapture();
try {
const result = await backupService.restoreBackup(name);
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'UPDATE', resourceType: 'Backup',
label: `Backup ${name} wiederhergestellt`,
@@ -64,10 +222,35 @@ export async function restoreBackup(req: Request, res: Response) {
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
});
} else {
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({
error: 'Wiederherstellung fehlgeschlagen',
details: makeRestoreErrorReadable(result.error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
const durationMs = Date.now() - start;
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({
error: 'Fehler bei der Wiederherstellung',
details: makeRestoreErrorReadable(error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
}
@@ -79,8 +262,8 @@ export async function deleteBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.deleteBackup(name);
@@ -107,8 +290,8 @@ export async function downloadBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.createBackupZip(name);
@@ -167,6 +350,22 @@ export async function uploadBackup(req: Request, res: Response) {
*/
export async function factoryReset(req: Request, res: Response) {
try {
// Bestätigung erforderlich: client MUSS explizit
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
// POST plätten (Pentest Runde 11 (2026-05-18) C2 KRITISCH:
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
// Replay-Angriff aus Versehen triggern kann.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
res.status(400).json({
success: false,
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
});
return;
}
const result = await backupService.factoryReset();
if (result.success) {
@@ -181,6 +380,65 @@ export async function factoryReset(req: Request, res: Response) {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
console.error('factoryReset error:', error);
}
}
/**
* Liste der Backup-Logs (CREATE oder RESTORE)
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
* Liefert die Übersichtsdaten OHNE den großen fullLog.
*/
export async function listBackupLogs(req: Request, res: Response) {
try {
const op = String(req.query.operation || '').toUpperCase();
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
const where: any = {};
if (op === 'CREATE' || op === 'RESTORE') {
where.operation = op;
}
const logs = await prisma.backupLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
operation: true,
backupName: true,
success: true,
durationMs: true,
summary: true,
userEmail: true,
ipAddress: true,
createdAt: true,
},
});
res.json({ data: logs });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
}
}
/**
* Detail eines Backup-Logs inkl. fullLog
* GET /api/settings/backup-logs/:id
*/
export async function getBackupLogDetail(req: Request, res: Response) {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
return res.status(400).json({ error: 'Ungültige ID' });
}
const log = await prisma.backupLog.findUnique({ where: { id } });
if (!log) {
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
}
res.json({ data: log });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
}
}
@@ -1,6 +1,9 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Admin/Mitarbeiter: Kommende und vergangene Geburtstage
@@ -8,6 +11,13 @@ import * as birthdayService from '../services/birthday.service.js';
*/
export async function getUpcomingBirthdays(req: AuthRequest, res: Response) {
try {
// Portal-Kunden haben hier nichts zu suchen. Endpoint listet Namen, E-Mail,
// Telefon und Geburtsdatum ALLER Kunden ausschließlich Mitarbeiter-UI.
// Pentest Runde 6 (2026-05-16) HOCH.
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Nicht erlaubt' });
return;
}
const past = req.query.past ? parseInt(String(req.query.past)) : 7;
const future = req.query.future ? parseInt(String(req.query.future)) : 30;
@@ -54,3 +64,125 @@ export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
/**
* Admin: Geburtstagsgruß-Marker für einen Kunden zurücksetzen (Debug / Re-Trigger).
*/
export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
await birthdayService.resetBirthdayGreeting(customerId);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'UPDATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß-Marker zurückgesetzt`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({ success: true });
} catch (error) {
console.error('Fehler beim Zurücksetzen:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
});
}
}
/**
* Admin: Geburtstagsgruß manuell senden (Email oder Link für WhatsApp/Telegram/Signal).
*/
export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal'
if (!['email', 'whatsapp', 'telegram', 'signal'].includes(channel)) {
return res.status(400).json({ success: false, error: 'Ungültiger Kanal' });
}
const data = await birthdayService.getBirthdayGreetingData(customerId);
if (!data) {
return res.status(400).json({
success: false,
error: 'Kunde hat kein Geburtsdatum hinterlegt',
});
}
const { subject, plain, html } = birthdayService.buildBirthdayGreetingText(data, data.age);
if (channel === 'email') {
if (!data.email) {
return res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
});
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: data.email,
subject,
html,
}, {
context: 'birthday-greeting',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'CREATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({
success: true,
data: { channel, messageText: plain },
});
} catch (error) {
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
+319 -73
View File
@@ -8,20 +8,42 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
import fs from 'fs';
import {
canAccessCustomer,
canAccessContract,
canAccessCachedEmail,
canAccessStressfreiEmail,
} from '../utils/accessControl.js';
// ==================== E-MAIL LIST ====================
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
function parseBoolParam(v: unknown): boolean | undefined {
if (v === 'true') return true;
if (v === 'false') return false;
return undefined;
}
function parseDateParam(v: unknown): Date | undefined {
if (typeof v !== 'string' || !v.trim()) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
// E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
@@ -34,6 +56,17 @@ export async function getEmailsForCustomer(req: Request, res: Response): Promise
limit,
offset,
includeBody: false,
search: typeof req.query.search === 'string' ? req.query.search : undefined,
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
hasAttachments: parseBoolParam(req.query.hasAttachments),
isRead: parseBoolParam(req.query.isRead),
isStarred: parseBoolParam(req.query.isStarred),
receivedFrom: parseDateParam(req.query.receivedFrom),
receivedTo: parseDateParam(req.query.receivedTo),
});
res.json({ success: true, data: emails } as ApiResponse);
@@ -47,9 +80,10 @@ export async function getEmailsForCustomer(req: Request, res: Response): Promise
}
// E-Mails für einen Vertrag abrufen
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
@@ -75,9 +109,11 @@ export async function getEmailsForContract(req: Request, res: Response): Promise
// ==================== SINGLE EMAIL ====================
// Einzelne E-Mail abrufen (mit Body)
export async function getEmail(req: Request, res: Response): Promise<void> {
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const email = await cachedEmailService.getCachedEmailById(id);
if (!email) {
@@ -102,9 +138,10 @@ export async function getEmail(req: Request, res: Response): Promise<void> {
}
// E-Mail als gelesen/ungelesen markieren
export async function markAsRead(req: Request, res: Response): Promise<void> {
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const { isRead } = req.body;
if (isRead) {
@@ -124,9 +161,10 @@ export async function markAsRead(req: Request, res: Response): Promise<void> {
}
// E-Mail Stern umschalten
export async function toggleStar(req: Request, res: Response): Promise<void> {
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const isStarred = await cachedEmailService.toggleEmailStar(id);
res.json({ success: true, data: { isStarred } } as ApiResponse);
@@ -142,10 +180,12 @@ export async function toggleStar(req: Request, res: Response): Promise<void> {
// ==================== CONTRACT ASSIGNMENT ====================
// E-Mail einem Vertrag zuordnen
export async function assignToContract(req: Request, res: Response): Promise<void> {
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { contractId } = req.body;
if (!(await canAccessContract(req, res, contractId))) return;
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
@@ -161,9 +201,10 @@ export async function assignToContract(req: Request, res: Response): Promise<voi
}
// Vertragszuordnung aufheben
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const email = await cachedEmailService.unassignEmailFromContract(emailId);
@@ -178,9 +219,10 @@ export async function unassignFromContract(req: Request, res: Response): Promise
}
// E-Mail-Anzahl pro Ordner für ein Konto
export async function getFolderCounts(req: Request, res: Response): Promise<void> {
export async function getFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
@@ -195,9 +237,10 @@ export async function getFolderCounts(req: Request, res: Response): Promise<void
}
// E-Mail-Anzahl pro Ordner für einen Vertrag
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
@@ -214,9 +257,10 @@ export async function getContractFolderCounts(req: Request, res: Response): Prom
// ==================== SYNC & SEND ====================
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
export async function syncAccount(req: Request, res: Response): Promise<void> {
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const fullSync = req.query.full === 'true';
// Synchronisiert sowohl INBOX als auch SENT
@@ -246,12 +290,31 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
}
}
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
function hasCRLF(value: unknown): boolean {
if (typeof value === 'string') return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some(hasCRLF);
return false;
}
// E-Mail senden
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
res.status(400).json({
success: false,
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -396,9 +459,10 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
// ==================== ATTACHMENTS ====================
// Anhang-Liste einer E-Mail abrufen
export async function getAttachments(req: Request, res: Response): Promise<void> {
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -429,11 +493,14 @@ export async function getAttachments(req: Request, res: Response): Promise<void>
}
// Einzelnen Anhang herunterladen
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
const filename = decodeURIComponent(req.params.filename);
// Portal-Isolation: nur eigene/vertretene Emails
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
@@ -500,17 +567,48 @@ export async function downloadAttachment(req: Request, res: Response): Promise<v
return;
}
// Datei senden - inline (öffnen) oder attachment (download)
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
res.setHeader('Content-Type', attachment.contentType);
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
res.setHeader('Content-Length', attachment.size);
res.send(attachment.content);
} catch (error) {
console.error('downloadAttachment error:', error);
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
const lower = rawMsg.toLowerCase();
let friendly = rawMsg;
if (lower.includes('socket disconnected') && lower.includes('tls')) {
friendly =
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
} else if (lower.includes('econnrefused')) {
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
} else if (lower.includes('etimedout')) {
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
} else if (lower.includes('authentication') || lower.includes('auth')) {
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
}
res.status(500).json({
success: false,
error: 'Fehler beim Herunterladen des Anhangs',
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
} as ApiResponse);
}
}
@@ -518,9 +616,10 @@ export async function downloadAttachment(req: Request, res: Response): Promise<v
// ==================== MAILBOX ACCOUNTS ====================
// Mailbox-Konten eines Kunden abrufen
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
@@ -535,9 +634,10 @@ export async function getMailboxAccounts(req: Request, res: Response): Promise<v
}
// Mailbox nachträglich aktivieren
export async function enableMailbox(req: Request, res: Response): Promise<void> {
export async function enableMailbox(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.enableMailbox(id);
@@ -560,9 +660,10 @@ export async function enableMailbox(req: Request, res: Response): Promise<void>
}
// Mailbox-Status mit Provider synchronisieren
export async function syncMailboxStatus(req: Request, res: Response): Promise<void> {
export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, id))) return;
const result = await stressfreiEmailService.syncMailboxStatus(id);
@@ -591,9 +692,10 @@ export async function syncMailboxStatus(req: Request, res: Response): Promise<vo
}
// E-Mail-Thread abrufen
export async function getThread(req: Request, res: Response): Promise<void> {
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const thread = await cachedEmailService.getEmailThread(id);
@@ -608,9 +710,13 @@ export async function getThread(req: Request, res: Response): Promise<void> {
}
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
export async function getMailboxCredentials(req: Request, res: Response): Promise<void> {
export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
// Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit
// bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials
// eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX.
if (!(await canAccessStressfreiEmail(req, res, id))) return;
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
@@ -645,6 +751,15 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
// IMAP/SMTP-Einstellungen laden
const settings = await getImapSmtpSettings();
// Klartext-Mailbox-Passwort-Read auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'MailboxCredentials',
resourceId: id.toString(),
label: `Klartext-Mailbox-Zugangsdaten von ${stressfreiEmail.email} entschlüsselt`,
});
res.json({
success: true,
data: {
@@ -672,7 +787,7 @@ export async function getMailboxCredentials(req: Request, res: Response): Promis
}
// Ungelesene E-Mails zählen
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
@@ -680,8 +795,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
let count = 0;
if (customerId) {
if (!(await canAccessCustomer(req, res, customerId))) return;
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
} else if (contractId) {
if (!(await canAccessContract(req, res, contractId))) return;
count = await cachedEmailService.getUnreadCountForContract(contractId);
}
@@ -696,9 +813,10 @@ export async function getUnreadCount(req: Request, res: Response): Promise<void>
}
// E-Mail in Papierkorb verschieben (nur Admin)
export async function deleteEmail(req: Request, res: Response): Promise<void> {
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
// Prüfen ob E-Mail existiert
const email = await cachedEmailService.getCachedEmailById(id);
@@ -733,9 +851,10 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
// ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const emails = await cachedEmailService.getTrashEmails(customerId);
@@ -750,9 +869,10 @@ export async function getTrashEmails(req: Request, res: Response): Promise<void>
}
// Papierkorb-Anzahl für einen Kunden
export async function getTrashCount(req: Request, res: Response): Promise<void> {
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const count = await cachedEmailService.getTrashCount(customerId);
@@ -767,9 +887,10 @@ export async function getTrashCount(req: Request, res: Response): Promise<void>
}
// E-Mail aus Papierkorb wiederherstellen
export async function restoreEmail(req: Request, res: Response): Promise<void> {
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.restoreEmailFromTrash(id);
@@ -792,9 +913,10 @@ export async function restoreEmail(req: Request, res: Response): Promise<void> {
}
// E-Mail endgültig löschen (aus Papierkorb)
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const result = await cachedEmailService.permanentDeleteEmail(id);
@@ -819,9 +941,10 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise
// ==================== ATTACHMENT TARGETS ====================
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
export async function getAttachmentTargets(req: Request, res: Response): Promise<void> {
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail mit StressfreiEmail laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -1001,9 +1124,10 @@ export async function getAttachmentTargets(req: Request, res: Response): Promise
}
// E-Mail-Anhang in ein Dokumentenfeld speichern
export async function saveAttachmentTo(req: Request, res: Response): Promise<void> {
export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { entityType, entityId, targetKey } = req.body;
@@ -1288,9 +1412,10 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
// ==================== SAVE EMAIL AS PDF ====================
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { entityType, entityId, targetKey } = req.body;
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
@@ -1535,9 +1660,10 @@ export async function saveEmailAsPdf(req: Request, res: Response): Promise<void>
// ==================== SAVE EMAIL AS INVOICE ====================
// E-Mail als PDF exportieren und als Rechnung speichern
export async function saveEmailAsInvoice(req: Request, res: Response): Promise<void> {
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
@@ -1579,7 +1705,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
// Vertrag laden und prüfen ob es ein Energievertrag ist
// Vertrag laden
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1593,22 +1719,6 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Empfänger-Adressen parsen (JSON Array)
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
@@ -1645,8 +1755,15 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// PDF speichern
fs.writeFileSync(filePath, pdfBuffer);
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
@@ -1670,9 +1787,10 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// ==================== SAVE ATTACHMENT AS INVOICE ====================
// E-Mail-Anhang als Rechnung speichern
export async function saveAttachmentAsInvoice(req: Request, res: Response): Promise<void> {
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { invoiceDate, invoiceType, notes } = req.body;
@@ -1715,7 +1833,7 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
// Vertrag laden und prüfen ob es ein Energievertrag ist
// Vertrag laden
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1729,22 +1847,6 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
@@ -1816,8 +1918,15 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
// Datei speichern
fs.writeFileSync(filePath, attachment.content);
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
@@ -1837,3 +1946,140 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
} as ApiResponse);
}
}
/**
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
*/
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body;
if (!documentType || typeof documentType !== 'string') {
res.status(400).json({
success: false,
error: 'documentType ist erforderlich',
} as ApiResponse);
return;
}
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
return;
}
if (!email.contractId) {
res.status(400).json({
success: false,
error: 'E-Mail ist keinem Vertrag zugeordnet',
} as ApiResponse);
return;
}
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
select: { id: true, contractNumber: true, customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
return;
}
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
success: false,
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
} as ApiResponse);
return;
}
// StressfreiEmail für IMAP-Zugangsdaten
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
res.status(400).json({
success: false,
error: 'Keine Mailbox-Zugangsdaten verfügbar',
} as ApiResponse);
return;
}
const settings = await getImapSmtpSettings();
if (!settings) {
res.status(400).json({
success: false,
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
} as ApiResponse);
return;
}
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
if (!attachment) {
res.status(404).json({
success: false,
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
} as ApiResponse);
return;
}
// Uploads-Verzeichnis
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const ext = path.extname(filename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
}
}
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
}
}
res.json({ success: true, data: results });
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
// keine internen IDs das war früher der volle CustomerConsent-Record und
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
res.json({
success: true,
data: { granted: results.length },
});
} catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
+227 -22
View File
@@ -6,6 +6,32 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
* Strip safe.
*/
function sanitizeContractBody(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') return stripHtml(body);
if (Array.isArray(body)) return body.map(sanitizeContractBody);
if (typeof body === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
out[k] = sanitizeContractBody(v);
}
return out;
}
return body;
}
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -44,9 +70,15 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractsStrict(result.contracts as any[])
: sanitizeContracts(result.contracts as any[]);
res.json({
success: true,
data: result.contracts,
data,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
@@ -87,7 +119,11 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
res.json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -96,16 +132,30 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
export async function createContract(req: Request, res: Response): Promise<void> {
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contract = await contractService.createContract(req.body);
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
const body = (req.body || {}) as Record<string, unknown>;
if (!body.type || typeof body.type !== 'string') {
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
return;
}
if (!body.customerId || typeof body.customerId !== 'number') {
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const sanitizedBody = sanitizeContractBody(body);
const contract = await contractService.createContract(sanitizedBody as any);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `Vertrag ${contract.contractNumber} angelegt`,
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -123,7 +173,9 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
});
const contract = await contractService.updateContract(contractId, req.body);
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
const sanitizedBody = sanitizeContractBody(req.body);
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
@@ -138,11 +190,14 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
};
const energyLabels: Record<string, string> = {
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
};
// Hauptfelder vergleichen
const body = req.body;
// Hauptfelder vergleichen gegen die SANITISIERTE Version, damit
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
// rohen Request-Body mit ggf. gestrippter HTML.
const body = sanitizedBody as any;
if (before) {
for (const [key, newVal] of Object.entries(body)) {
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
@@ -177,7 +232,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
customerId: before?.customerId,
});
res.json({ success: true, data: contract } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordEncrypted etc.
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -209,6 +270,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
// Vorgängervertrag laden für Vertragsnummer
const previousContract = await prisma.contract.findUnique({
@@ -245,7 +307,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -254,9 +318,67 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
}
}
export async function getContractPassword(req: Request, res: Response): Promise<void> {
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
*/
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
try {
const password = await contractService.getContractPassword(parseInt(req.params.id));
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
const previousContract = await prisma.contract.findUnique({
where: { id: previousContractId },
select: { contractNumber: true },
});
if (!previousContract) {
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
return;
}
const contract = await contractService.createRenewalContract(previousContractId);
if (!contract) {
res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse);
return;
}
const createdBy = req.user?.email || 'unbekannt';
await contractHistoryService.createRenewalHistoryEntry(
previousContractId,
contract.contractNumber,
createdBy,
);
await contractHistoryService.createNewRenewalFromPredecessorEntry(
contract.id,
previousContract.contractNumber,
createdBy,
);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `VVL erstellt für ${previousContract.contractNumber}`,
customerId: contract.customerId,
});
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
} as ApiResponse);
}
}
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const password = await contractService.getContractPassword(contractId);
if (password === null) {
res.status(404).json({
success: false,
@@ -264,6 +386,14 @@ export async function getContractPassword(req: Request, res: Response): Promise<
} as ApiResponse);
return;
}
// Klartext-Passwort-Read auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'ContractPassword',
resourceId: contractId.toString(),
label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -273,9 +403,29 @@ export async function getContractPassword(req: Request, res: Response): Promise<
}
}
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
const simCardId = parseInt(req.params.simCardId);
// SimCard → MobileDetails → Contract
const sim = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { mobileDetails: { select: { contractId: true } } },
});
if (!sim?.mobileDetails) {
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
const credentials = await contractService.getSimCardCredentials(simCardId);
// Klartext-Read (PIN/PUK) auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SimCardCredentials',
resourceId: simCardId.toString(),
label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -285,9 +435,20 @@ export async function getSimCardCredentials(req: Request, res: Response): Promis
}
}
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const credentials = await contractService.getInternetCredentials(contractId);
// Klartext-DSL/Internet-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'InternetCredentials',
resourceId: contractId.toString(),
label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -297,9 +458,29 @@ export async function getInternetCredentials(req: Request, res: Response): Promi
}
}
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
const phoneNumberId = parseInt(req.params.phoneNumberId);
// PhoneNumber → InternetDetails → Contract
const phone = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { internetDetails: { select: { contractId: true } } },
});
if (!phone?.internetDetails) {
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
const credentials = await contractService.getSipCredentials(phoneNumberId);
// Klartext-SIP/Telefon-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SipCredentials',
resourceId: phoneNumberId.toString(),
label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -313,7 +494,22 @@ export async function getSipCredentials(req: Request, res: Response): Promise<vo
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
try {
const cockpitData = await contractCockpitService.getCockpitData();
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
// (Pentest Runde 4, 2026-05-16: HOCH).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
res.json({ success: true, data: cockpitData } as ApiResponse);
} catch (error) {
console.error('Cockpit error:', error);
@@ -395,6 +591,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
try {
const contractMeterId = parseInt(req.params.contractMeterId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
await logChange({
req, action: 'DELETE', resourceType: 'ContractMeter',
@@ -415,6 +612,8 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const documents = await prisma.contractDocument.findMany({
where: { contractId },
orderBy: { createdAt: 'desc' },
@@ -428,7 +627,8 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
const { documentType, notes } = req.body;
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
@@ -461,6 +661,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
customerId: contract?.customerId,
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
@@ -474,6 +677,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
try {
const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) {
@@ -511,9 +715,10 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: Request, res: Response): Promise<void> {
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessContract(req, res, id))) return;
const { nextReviewDate, months } = req.body;
let reviewDate: Date | null = null;
@@ -2,10 +2,12 @@ import { Request, Response } from 'express';
import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getHistoryEntries(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entries = await contractHistoryService.getHistoryEntries(contractId);
res.json({ success: true, data: entries } as ApiResponse);
} catch (error) {
@@ -19,6 +21,7 @@ export async function getHistoryEntries(req: AuthRequest, res: Response): Promis
export async function createHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const { title, description } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
@@ -54,6 +57,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
export async function updateHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId);
const { title, description } = req.body;
@@ -80,6 +84,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const entryId = parseInt(req.params.entryId);
await contractHistoryService.deleteHistoryEntry(contractId, entryId);
@@ -5,19 +5,30 @@ import * as customerService from '../services/customer.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, getPortalAllowedCustomerIds } from '../utils/accessControl.js';
// ==================== ALL TASKS (Dashboard & Task List) ====================
export async function getAllTasks(req: AuthRequest, res: Response): Promise<void> {
try {
const { status, customerId } = req.query;
const customerIdNum = customerId ? parseInt(customerId as string) : undefined;
// Für Kundenportal: Filter auf erlaubte Kunden
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
// Wenn der Portal-User explizit nach einer customerId filtert, die er
// nicht (mehr) vertreten darf → 403 statt 200 mit leerem Array
// (Pentest Runde 10 LOW: konsistentes Response-Verhalten nach
// Vollmacht-Widerruf).
if (customerIdNum !== undefined && !allowedIds.includes(customerIdNum)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' } as ApiResponse);
return;
}
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -26,7 +37,7 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
const tasks = await contractTaskService.getAllTasks({
status: status as 'OPEN' | 'COMPLETED' | undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
customerId: customerIdNum,
customerPortalCustomerIds,
customerPortalEmails,
});
@@ -42,12 +53,13 @@ export async function getAllTasks(req: AuthRequest, res: Response): Promise<void
export async function getTaskStats(req: AuthRequest, res: Response): Promise<void> {
try {
// Für Kundenportal: Filter auf erlaubte Kunden
// Für Kundenportal: Filter auf erlaubte Kunden (mit Live-Vollmacht-Check)
const allowedIds = await getPortalAllowedCustomerIds(req);
let customerPortalCustomerIds: number[] | undefined;
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerPortalCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (allowedIds) {
customerPortalCustomerIds = allowedIds;
const customers = await customerService.getCustomersByIds(customerPortalCustomerIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
@@ -75,33 +87,17 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
const contractId = parseInt(req.params.contractId);
const { status } = req.query;
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zentraler canAccessContract-Check inkl. Live-Vollmacht-Prüfung über
// hasAuthorization (Pentest Runde 6 HOCH-04: widerrufene Vollmachten
// hatten vorher weiter Zugriff, weil nur representedCustomerIds-Array
// konsultiert wurde, ohne Status-Check).
if (!(await canAccessContract(req, res, contractId))) return;
// Für Kundenportal: Zugriffsprüfung
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden
// Für Kundenportal-Benutzer: Lade E-Mails der erlaubten Kunden (mit Live-Vollmacht-Check)
let customerPortalEmails: string[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedIds = await getPortalAllowedCustomerIds(req);
if (allowedIds) {
const customers = await customerService.getCustomersByIds(allowedIds);
customerPortalEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
@@ -187,27 +183,8 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe Zugriff auf den Vertrag
const contract = await contractService.getContractById(contractId);
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Zugriffsprüfung für Kundenportal
if (req.user?.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
// canAccessContract inkl. Live-Vollmacht-Prüfung (siehe getTasks).
if (!(await canAccessContract(req, res, contractId))) return;
const createdBy = req.user?.email;
@@ -376,15 +353,26 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return;
}
// Prüfe ob der Kunde berechtigt ist (eigenes Ticket oder freigegebener Kunde)
if (req.user?.isCustomerPortal && req.user.customerId) {
if (!req.user?.isCustomerPortal || !req.user.customerId) {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
} as ApiResponse);
return;
}
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
// via hasAuthorization, Pentest Runde 6 HOCH-04). Damit kann ein
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
if (!(await canAccessContract(req, res, task.contractId))) return;
// Zusätzlich: portal-User darf nur antworten, wenn die Task von ihm
// initiiert wurde ODER explizit für ihn sichtbar markiert ist.
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
const customers = await customerService.getCustomersByIds(allowedCustomerIds);
const allowedEmails = customers
.map((c: { id: number; portalEmail: string | null }) => c.portalEmail)
.filter((email: string | null): email is string => !!email);
// Task muss entweder visibleInPortal sein ODER vom Kunden erstellt worden sein
const isOwnTask = task.createdBy && allowedEmails.includes(task.createdBy);
if (!task.visibleInPortal && !isOwnTask) {
res.status(403).json({
@@ -393,13 +381,6 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
} as ApiResponse);
return;
}
} else {
res.status(403).json({
success: false,
error: 'Nur für Kundenportal-Benutzer',
} as ApiResponse);
return;
}
const createdBy = req.user?.email;
+300 -61
View File
@@ -3,19 +3,51 @@ import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
sanitizeCustomers,
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
isValidEmail,
} from '../utils/sanitize.js';
import {
canAccessMeter,
canAccessAddress,
canAccessBankCard,
canAccessIdentityDocument,
canAccessCustomer,
getPortalAllowedCustomerIds,
} from '../utils/accessControl.js';
// Customer CRUD
export async function getCustomers(req: Request, res: Response): Promise<void> {
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
try {
const { search, type, page, limit } = req.query;
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver
// Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit
// auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6
// MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl).
const allowedIds = await getPortalAllowedCustomerIds(req);
const result = await customerService.getAllCustomers({
search: search as string,
type: type as 'PRIVATE' | 'BUSINESS',
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
allowedIds: allowedIds ?? undefined,
});
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
const customers = result.customers as any[];
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomers(customers)
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -24,14 +56,21 @@ export async function getCustomers(req: Request, res: Response): Promise<void> {
}
}
export async function getCustomer(req: Request, res: Response): Promise<void> {
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customer = await customerService.getCustomerById(parseInt(req.params.id));
const customerId = parseInt(req.params.id);
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await customerService.getCustomerById(customerId);
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
res.json({ success: true, data: customer } as ApiResponse);
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
}
@@ -39,7 +78,18 @@ export async function getCustomer(req: Request, res: Response): Promise<void> {
export async function createCustomer(req: Request, res: Response): Promise<void> {
try {
const data = { ...req.body };
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
if (data.email && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Convert birthDate string to Date if present
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -51,7 +101,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
customerId: customer.id,
});
res.status(201).json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -63,7 +120,17 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
export async function updateCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.id);
const data = { ...req.body };
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (req.body?.email && !isValidEmail(req.body.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
const data: any = pickCustomerUpdate(req.body);
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ where: { id: customerId } });
@@ -88,6 +155,9 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
useInformalAddress: 'Anrede per',
autoBirthdayGreeting: 'Autom. Geburtstagsgruß',
autoBirthdayChannel: 'Kanal für Geburtstagsgruß',
};
for (const [key, value] of Object.entries(data)) {
// Technische/interne Felder überspringen
@@ -126,7 +196,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
}
}
res.json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordHash +
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
console.error('Update customer error:', error);
res.status(400).json({
@@ -157,18 +234,21 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
}
// Addresses
export async function getAddresses(req: Request, res: Response): Promise<void> {
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
try {
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const addresses = await customerService.getCustomerAddresses(customerId);
res.json({ success: true, data: addresses } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
}
}
export async function createAddress(req: Request, res: Response): Promise<void> {
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const address = await customerService.createAddress(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Address',
@@ -185,9 +265,10 @@ export async function createAddress(req: Request, res: Response): Promise<void>
}
}
export async function updateAddress(req: Request, res: Response): Promise<void> {
export async function updateAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -248,9 +329,10 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
}
}
export async function deleteAddress(req: Request, res: Response): Promise<void> {
export async function deleteAddress(req: AuthRequest, res: Response): Promise<void> {
try {
const addressId = parseInt(req.params.id);
if (!(await canAccessAddress(req, res, addressId))) return;
const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } });
const customerId = addr?.customerId;
await customerService.deleteAddress(addressId);
@@ -270,22 +352,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
}
// Bank Cards
export async function getBankCards(req: Request, res: Response): Promise<void> {
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const cards = await customerService.getCustomerBankCards(
parseInt(req.params.customerId),
showInactive
);
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
res.json({ success: true, data: cards } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
}
}
export async function createBankCard(req: Request, res: Response): Promise<void> {
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const card = await customerService.createBankCard(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'BankCard',
@@ -302,9 +384,10 @@ export async function createBankCard(req: Request, res: Response): Promise<void>
}
}
export async function updateBankCard(req: Request, res: Response): Promise<void> {
export async function updateBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -360,9 +443,10 @@ export async function updateBankCard(req: Request, res: Response): Promise<void>
}
}
export async function deleteBankCard(req: Request, res: Response): Promise<void> {
export async function deleteBankCard(req: AuthRequest, res: Response): Promise<void> {
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } });
const customerId = card?.customerId;
await customerService.deleteBankCard(cardId);
@@ -382,22 +466,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
}
// Identity Documents
export async function getDocuments(req: Request, res: Response): Promise<void> {
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const docs = await customerService.getCustomerDocuments(
parseInt(req.params.customerId),
showInactive
);
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
res.json({ success: true, data: docs } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
}
}
export async function createDocument(req: Request, res: Response): Promise<void> {
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const doc = await customerService.createDocument(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'IdentityDocument',
@@ -414,9 +498,10 @@ export async function createDocument(req: Request, res: Response): Promise<void>
}
}
export async function updateDocument(req: Request, res: Response): Promise<void> {
export async function updateDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -478,9 +563,10 @@ export async function updateDocument(req: Request, res: Response): Promise<void>
}
}
export async function deleteDocument(req: Request, res: Response): Promise<void> {
export async function deleteDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const docId = parseInt(req.params.id);
if (!(await canAccessIdentityDocument(req, res, docId))) return;
const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } });
const customerId = doc?.customerId;
await customerService.deleteDocument(docId);
@@ -500,22 +586,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
}
// Meters
export async function getMeters(req: Request, res: Response): Promise<void> {
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const meters = await customerService.getCustomerMeters(
parseInt(req.params.customerId),
showInactive
);
const meters = await customerService.getCustomerMeters(customerId, showInactive);
res.json({ success: true, data: meters } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
}
}
export async function createMeter(req: Request, res: Response): Promise<void> {
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Meter',
@@ -532,9 +618,10 @@ export async function createMeter(req: Request, res: Response): Promise<void> {
}
}
export async function updateMeter(req: Request, res: Response): Promise<void> {
export async function updateMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
const data = req.body;
// Vorherigen Stand laden für Audit
@@ -589,9 +676,10 @@ export async function updateMeter(req: Request, res: Response): Promise<void> {
}
}
export async function deleteMeter(req: Request, res: Response): Promise<void> {
export async function deleteMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.id);
if (!(await canAccessMeter(req, res, meterId))) return;
await customerService.deleteMeter(meterId);
await logChange({
req, action: 'DELETE', resourceType: 'Meter',
@@ -608,19 +696,22 @@ export async function deleteMeter(req: Request, res: Response): Promise<void> {
}
// Meter Readings
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
try {
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readings = await customerService.getMeterReadings(meterId);
res.json({ success: true, data: readings } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
}
}
export async function addMeterReading(req: Request, res: Response): Promise<void> {
export async function addMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const { readingDate, value, valueNt, unit, notes } = req.body;
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const reading = await customerService.addMeterReading(meterId, {
readingDate: new Date(readingDate),
value: parseFloat(value),
@@ -653,8 +744,10 @@ export async function addMeterReading(req: Request, res: Response): Promise<void
}
}
export async function updateMeterReading(req: Request, res: Response): Promise<void> {
export async function updateMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const { readingDate, value, valueNt, unit, notes } = req.body;
const updateData: Record<string, unknown> = {};
if (readingDate !== undefined) updateData.readingDate = new Date(readingDate);
@@ -664,7 +757,7 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
if (notes !== undefined) updateData.notes = notes;
const reading = await customerService.updateMeterReading(
parseInt(req.params.meterId),
meterId,
parseInt(req.params.readingId),
updateData as any
);
@@ -682,13 +775,12 @@ export async function updateMeterReading(req: Request, res: Response): Promise<v
}
}
export async function deleteMeterReading(req: Request, res: Response): Promise<void> {
export async function deleteMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
await customerService.deleteMeterReading(
parseInt(req.params.meterId),
readingId
);
await customerService.deleteMeterReading(meterId, readingId);
await logChange({
req, action: 'DELETE', resourceType: 'MeterReading',
resourceId: readingId.toString(),
@@ -789,6 +881,7 @@ export async function getMyMeters(req: AuthRequest, res: Response): Promise<void
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readingId = parseInt(req.params.readingId);
const reading = await prisma.meterReading.update({
@@ -817,9 +910,11 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
// ==================== PORTAL SETTINGS ====================
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
try {
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const settings = await customerService.getPortalSettings(customerId);
if (!settings) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
@@ -846,7 +941,27 @@ export async function getPortalSettings(req: Request, res: Response): Promise<vo
export async function updatePortalSettings(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const { portalEnabled, portalEmail } = req.body;
// `password` (oder password-ähnliche Felder) gehören NICHT in den
// Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt
// (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer
// ein Passwort setzen will, nutzt POST /portal/password mit
// Komplexitäts-Check. (Pentest-Befund.)
const body = req.body || {};
const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`,
} as ApiResponse);
return;
}
const { portalEnabled, portalEmail } = body;
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4)
if (portalEmail && !isValidEmail(portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({
@@ -906,13 +1021,115 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
}
}
/**
* Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt).
* Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert.
* Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern).
*/
export async function generatePortalPassword(req: Request, res: Response): Promise<void> {
try {
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
}
}
/**
* Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte
* `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das
* Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt
* (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird).
*/
export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true, firstName: true, lastName: true, salutation: true, companyName: true,
email: true, portalEmail: true, portalEnabled: true,
portalPasswordEncrypted: true, portalPasswordHash: true,
},
});
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
if (!customer.portalEnabled) {
res.status(400).json({
success: false,
error: 'Portal ist für diesen Kunden nicht aktiviert',
} as ApiResponse);
return;
}
if (!customer.portalPasswordHash) {
res.status(400).json({
success: false,
error: 'Es ist noch kein Portal-Passwort gesetzt',
} as ApiResponse);
return;
}
const targetEmail = customer.email || customer.portalEmail;
if (!targetEmail) {
res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
} as ApiResponse);
return;
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
});
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
// der Kunde sich ein eigenes setzen.
await authService.markPortalPasswordForChange(customerId);
await logChange({
req,
action: 'UPDATE',
resourceType: 'PortalSettings',
resourceId: customerId.toString(),
label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`,
customerId,
});
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
}
}
export async function setPortalPassword(req: Request, res: Response): Promise<void> {
try {
const { password } = req.body;
if (!password || password.length < 6) {
// Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel)
const complexity = validatePasswordComplexity(password);
if (!complexity.ok) {
res.status(400).json({
success: false,
error: 'Passwort muss mindestens 6 Zeichen lang sein',
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '),
} as ApiResponse);
return;
}
@@ -933,9 +1150,22 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
}
}
export async function getPortalPassword(req: Request, res: Response): Promise<void> {
export async function getPortalPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const password = await authService.getCustomerPortalPassword(parseInt(req.params.customerId));
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const password = await authService.getCustomerPortalPassword(customerId);
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
// + Insider-Threat-Erkennung.
await logChange({
req,
action: 'READ',
resourceType: 'PortalPassword',
resourceId: customerId.toString(),
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
customerId,
});
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -947,10 +1177,12 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
// ==================== REPRESENTATIVE MANAGEMENT ====================
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Wer kann diesen Kunden vertreten (representedBy)?
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
const representedBy = await customerService.getRepresentedByList(customerId);
res.json({ success: true, data: representedBy } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -960,9 +1192,10 @@ export async function getRepresentatives(req: Request, res: Response): Promise<v
}
}
export async function addRepresentative(req: Request, res: Response): Promise<void> {
export async function addRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { representativeId, notes } = req.body;
const representative = await customerService.addRepresentative(
customerId,
@@ -984,9 +1217,10 @@ export async function addRepresentative(req: Request, res: Response): Promise<vo
}
}
export async function removeRepresentative(req: Request, res: Response): Promise<void> {
export async function removeRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
await customerService.removeRepresentative(
customerId,
parseInt(req.params.representativeId)
@@ -1005,8 +1239,13 @@ export async function removeRepresentative(req: Request, res: Response): Promise
}
}
export async function searchForRepresentative(req: Request, res: Response): Promise<void> {
export async function searchForRepresentative(req: AuthRequest, res: Response): Promise<void> {
try {
// KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User
// mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette
// Kunden-DB-Enumeration via Buchstaben-Brute-Force.
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const { search } = req.query;
if (!search || typeof search !== 'string' || search.length < 2) {
res.json({ success: true, data: [] } as ApiResponse);
@@ -1014,7 +1253,7 @@ export async function searchForRepresentative(req: Request, res: Response): Prom
}
const customers = await customerService.searchCustomersForRepresentative(
search,
parseInt(req.params.customerId)
customerId,
);
res.json({ success: true, data: customers } as ApiResponse);
} catch (error) {
@@ -4,6 +4,14 @@ import { Request, Response } from 'express';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
import { decrypt } from '../utils/encryption.js';
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ==================== CONFIG CRUD ====================
@@ -112,6 +120,33 @@ export async function testConnection(req: Request, res: Response): Promise<void>
domain: req.body.domain,
} : undefined;
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host');
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err.message,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { apiUrl: testData.apiUrl },
});
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
// URL-Parse-Fehler ignorieren Backend reagiert sowieso mit Fehler
}
}
const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
@@ -122,6 +157,188 @@ export async function testConnection(req: Request, res: Response): Promise<void>
}
}
/**
* Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers.
* - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal)
* - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert)
*/
export async function testMailAccess(req: Request, res: Response): Promise<void> {
try {
const id = req.body?.id ? parseInt(req.body.id) : undefined;
const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined;
const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined;
let emailAddress: string | undefined;
let password: string | undefined;
let smtpServer: string;
let smtpPort: number;
let imapServer: string;
let imapPort: number;
let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let allowSelfSignedCerts: boolean;
if (id) {
// Gespeicherten Provider laden
const config = await prisma.emailProviderConfig.findUnique({ where: { id } });
if (!config) {
res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse);
return;
}
emailAddress = bodyEmail || config.systemEmailAddress || undefined;
if (bodyPassword) {
password = bodyPassword;
} else if (config.systemEmailPasswordEncrypted) {
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
password = undefined;
}
}
// IMAP/SMTP-Settings vom Provider ableiten
const settings = await emailProviderService.getImapSmtpSettings();
if (!settings) {
res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse);
return;
}
smtpServer = settings.smtpServer;
smtpPort = settings.smtpPort;
imapServer = settings.imapServer;
imapPort = settings.imapPort;
smtpEncryption = settings.smtpEncryption;
imapEncryption = settings.imapEncryption;
allowSelfSignedCerts = settings.allowSelfSignedCerts;
} else if (req.body?.apiUrl) {
// Formulardaten ohne gespeicherten Provider
emailAddress = bodyEmail;
password = bodyPassword;
try {
const url = new URL(req.body.apiUrl);
smtpServer = url.hostname;
imapServer = url.hostname;
} catch {
smtpServer = `mail.${req.body.domain || ''}`;
imapServer = smtpServer;
}
imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts = !!req.body.allowSelfSignedCerts;
imapPort = imapEncryption === 'SSL' ? 993 : 143;
smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25;
} else {
res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse);
return;
}
if (!emailAddress || !password) {
res.status(400).json({
success: false,
error: 'System-E-Mail-Adresse und Passwort sind erforderlich',
} as ApiResponse);
return;
}
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
]);
} catch (err) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { smtpServer, imapServer },
});
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
} as ApiResponse);
return;
}
// IMAP testen
const imapCredentials: ImapCredentials = {
host: imapResolved.ip,
port: imapPort,
user: emailAddress,
password,
encryption: imapEncryption,
allowSelfSignedCerts,
servername: imapResolved.servername,
};
// SMTP testen
const smtpCredentials: SmtpCredentials = {
host: smtpResolved.ip,
port: smtpPort,
user: emailAddress,
password,
encryption: smtpEncryption,
allowSelfSignedCerts,
servername: smtpResolved.servername,
};
let imapResult: { success: boolean; error?: string } = { success: false };
let smtpResult: { success: boolean; error?: string } = { success: false };
try {
await testImapConnection(imapCredentials);
imapResult = { success: true };
} catch (e) {
imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
try {
await testSmtpConnection(smtpCredentials);
smtpResult = { success: true };
} catch (e) {
smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
res.json({
success: imapResult.success && smtpResult.success,
data: {
imap: {
...imapResult,
server: imapServer,
port: imapPort,
encryption: imapEncryption,
},
smtp: {
...smtpResult,
server: smtpServer,
port: smtpPort,
encryption: smtpEncryption,
},
user: emailAddress,
},
} as ApiResponse);
} catch (error) {
console.error('testMailAccess error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
}
}
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
try {
const { localPart } = req.params;
@@ -181,3 +398,20 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
} as ApiResponse);
}
}
/**
* Öffentliche Provider-Einstellungen für die Frontend-UI:
* Domain + Label für Kunden-E-Mail-Adressen.
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
*/
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
try {
const settings = await emailProviderService.getProviderPublicSettings();
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Einstellungen',
} as ApiResponse);
}
}
@@ -0,0 +1,101 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Factory-Defaults als ZIP exportieren (Download).
*/
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
try {
const buffer = await factoryDefaultsService.exportFactoryDefaults();
const dateStr = new Date().toISOString().split('T')[0];
const filename = `factory-defaults-${dateStr}.zip`;
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'FactoryDefaults',
resourceId: dateStr,
resourceLabel: 'Factory-Defaults exportiert',
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
}
}
/**
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
*/
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
try {
const data = await factoryDefaultsService.collectFactoryDefaults();
res.json({
success: true,
data: {
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
appSettings: data.appSettings.length,
},
},
});
} catch (error) {
console.error('Fehler beim Preview:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
*/
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
try {
const file = (req as any).file as Express.Multer.File | undefined;
if (!file || !file.buffer) {
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
}
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
// den Vorgang explizit als Import.
action: 'UPDATE',
resourceType: 'FactoryDefaults',
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Factory-Defaults-Import:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Import',
});
}
}
@@ -0,0 +1,98 @@
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import { findUploadOwner } from '../services/fileDownload.service.js';
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
/**
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
*
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
*
* Schritte:
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
* 3. canAccessCustomer / canAccessContract / Permission-Check
* 4. Datei senden (mit korrektem Content-Type)
*
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
* vertretenen Kunden mit Vollmacht) herunterladen nicht mehr beliebige
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
* mitgeschnitten hätte.
*/
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
const requested = typeof req.query.path === 'string' ? req.query.path : '';
if (!requested) {
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
return;
}
// Format-Validierung (Traversal-Schutz)
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
// Owner ermitteln
const owner = await findUploadOwner(requested);
if (!owner) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Access-Check je nach Owner-Typ
if (owner.kind === 'customer') {
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
} else if (owner.kind === 'contract') {
if (!(await canAccessContract(req, res, owner.contractId))) return;
} else if (owner.kind === 'admin') {
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
const perms = req.user?.permissions || [];
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
} else if (owner.kind === 'gdpr-admin') {
const perms = req.user?.permissions || [];
if (!perms.includes('gdpr:admin')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
}
// Datei vom Disk lesen
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
const relative = requested.substring('/uploads/'.length);
const absolute = path.join(process.cwd(), 'uploads', relative);
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
if (!absolute.startsWith(uploadsRoot)) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
if (!fs.existsSync(absolute)) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
// durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
// nicht, wenn der Server selbst text/html liefert.
//
// Fix: alle Files via Content-Disposition: attachment ausliefern.
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
// Für legitime PDF/Bild-Vorschau ist das vertretbar Browser
// öffnen den Download dann eben aus dem Datei-Manager.
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.sendFile(absolute);
}
+159 -28
View File
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
@@ -12,6 +13,7 @@ import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js';
import { stripHtml } from '../utils/sanitize.js';
/**
* Kundendaten exportieren (DSGVO Art. 15)
@@ -190,7 +192,12 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
}
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
const uploadsDir = path.resolve(process.cwd(), 'uploads');
const filepath = path.resolve(uploadsDir, request.proofDocument);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
@@ -224,6 +231,7 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
export async function getCustomerConsents(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen
@@ -246,6 +254,7 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
export async function checkConsentStatus(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result });
} catch (error) {
@@ -261,7 +270,14 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType;
const { status, source, documentPath, version } = req.body;
// BEWUSST nur `status` aus dem Body übernehmen. `source`, `documentPath`
// und `version` darf der Portal-User NICHT setzen Pentest 2026-05-20
// (MEDIUM): "ADMIN_OVERRIDE" als source bzw. "<script>" als version
// landeten vorher ungefiltert in der DB. source ist für diesen
// Endpoint immer 'portal'; documentPath wird ausschließlich vom
// Auth-Upload-Endpoint server-seitig gesetzt; version pflegt das CRM
// (falls überhaupt) später nach.
const { status } = req.body;
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
if (!(req.user as any)?.isCustomerPortal) {
@@ -271,17 +287,9 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
});
}
// Portal: nur eigene + vertretene Kunden
const allowed = [
(req.user as any).customerId,
...((req.user as any).representedCustomerIds || []),
];
if (!allowed.includes(customerId)) {
return res.status(403).json({
success: false,
error: 'Keine Berechtigung für diesen Kunden',
});
}
// canAccessCustomer inkl. Live-Vollmacht-Check (Pentest Runde 6 HOCH-04:
// widerrufene Vollmachten hatten vorher noch Zugriff)
if (!(await canAccessCustomer(req, res, customerId))) return;
if (!Object.values(ConsentType).includes(consentType)) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
@@ -296,9 +304,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: source || 'portal',
documentPath,
version,
source: 'portal',
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
@@ -307,7 +313,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'CustomerConsent',
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
details: { einwilligung: consentName, status, quelle: source || 'portal' },
details: { einwilligung: consentName, status, quelle: 'portal' },
customerId,
});
@@ -794,6 +800,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
@@ -813,9 +820,15 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
const { source, notes } = req.body;
// Whitelist erzwingen, sonst landen Phantasie-Werte wie "ADMIN_OVERRIDE"
// oder `<script>` in der DB (Pentest 2026-05-20). notes wird durch
// stripHtml geschickt (Plain-Text-Feld).
const safeSource = consentService.sanitizeConsentSource(source, 'crm-backend');
const safeNotes = typeof notes === 'string' ? stripHtml(notes) : notes;
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
source: source || 'crm-backend',
notes,
source: safeSource,
notes: safeNotes as string | undefined,
});
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
@@ -878,6 +891,78 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
}
// Strukturelle PDF-Validierung: multer prüft nur den client-gemeldeten
// MIME-Type, ein Angreifer kann beliebige Daten als "application/pdf"
// hochladen. Wir verlangen:
// 1) Magic-Bytes "%PDF-" am Anfang
// 2) "%%EOF"-Marker in den letzten 1024 Bytes (Standard-PDF-Ende)
// 3) keinen Shebang ("#!") und kein "<script"/"<?php" in den
// ersten 4 KB (Pentest 28.3 Partial: "%PDF-1.4\n#!/bin/bash"
// passierte die reine Magic-Byte-Prüfung).
// Wer trotzdem eine PDF mit eingebettetem JS hochlädt, bekommt das
// hier nicht erkannt aber das ist Adobe-Acrobat-Risiko und nicht
// mehr ein CRM-Backend-Bug. Hier geht's um simple File-Type-Spoofs.
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
try {
const stat = fs.statSync(req.file.path);
const fd = fs.openSync(req.file.path, 'r');
// Header
const head = Buffer.alloc(5);
fs.readSync(fd, head, 0, 5, 0);
if (!head.equals(PDF_MAGIC)) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
});
}
// Erste 4 KB scannen auf verbotene Marker (Shell-Script,
// HTML/PHP-Payload). Ein echtes PDF enthält am Anfang nur
// Binärdaten + ein paar ASCII-Marker, "#!" / "<script" sind
// klare Spoof-Indikatoren.
const headSize = Math.min(stat.size, 4096);
const headBuf = Buffer.alloc(headSize);
fs.readSync(fd, headBuf, 0, headSize, 0);
const headStr = headBuf.toString('latin1').toLowerCase();
const forbidden = ['#!/', '<script', '<?php', '<%', 'mz']; // last = PE/Windows exe
const hit = forbidden.find((m) => headStr.includes(m));
if (hit) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: `Datei enthält verdächtiges Payload-Pattern ("${hit}").`,
});
}
// EOF-Marker in den letzten 1 KB. Strikt PDF/A wäre genau am
// Dateiende, aber viele Tools schreiben Whitespace/Newlines
// nach %%EOF, deshalb prüfen wir das letzte KB.
if (stat.size >= 5) {
const tailSize = Math.min(stat.size, 1024);
const tailBuf = Buffer.alloc(tailSize);
fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
if (!tailBuf.toString('latin1').includes('%%EOF')) {
fs.closeSync(fd);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Datei ist keine gültige PDF (EOF-Marker fehlt).',
});
}
}
fs.closeSync(fd);
} catch (_e) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
return res.status(400).json({
success: false,
error: 'Hochgeladene Datei konnte nicht gelesen werden.',
});
}
const documentPath = `/uploads/authorizations/${req.file.filename}`;
const auth = await authorizationService.updateAuthorizationDocument(
customerId,
@@ -961,12 +1046,27 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body;
// Vertreter-Name laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { firstName: true, lastName: true },
// Validierungen:
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
if (representativeId === user.customerId) {
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
}
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
// DB enumerieren kann (kein 404-vs-403-Disclosure).
const relation = await prisma.customerRepresentative.findFirst({
where: { customerId: user.customerId, representativeId, isActive: true },
include: { representative: { select: { firstName: true, lastName: true } } },
});
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
if (!relation) {
return res.status(403).json({
success: false,
error: 'Kein Vertreter-Verhältnis Vollmacht nicht erlaubt',
});
}
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
let auth;
if (grant) {
@@ -982,10 +1082,9 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
});
// Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
// sollten nicht an Endkunden geleakt werden.
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
}
}
@@ -1015,3 +1114,35 @@ export async function getMyAuthorizationStatus(req: AuthRequest, res: Response)
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Unterschreibbare Datenschutzerklärung (Papierform) als PDF generieren.
* Verwendung: Mitarbeiter klickt im Tab "Einwilligungen / Datenschutz"
* auf "Vorlage zum Unterschreiben", PDF kommt mit personalisiertem
* Kopf + Unterschriftsfeld zum Ausdrucken zurück.
*
* GET /api/gdpr/customer/:customerId/privacy-pdf
*/
export async function getSignablePrivacyPdf(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId, 10);
if (!Number.isFinite(customerId) || customerId < 1) {
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const pdf = await consentPublicService.generateSignablePrivacyPdf(customerId);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { customerNumber: true },
});
const filename = `datenschutzerklaerung-${customer?.customerNumber || customerId}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(pdf);
} catch (error) {
console.error('Fehler bei Datenschutz-PDF:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren der PDF' });
}
}
+16 -8
View File
@@ -1,14 +1,16 @@
import { Request, Response } from 'express';
import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
/**
* Alle Rechnungen für ein EnergyContractDetails abrufen
*/
export async function getInvoices(req: Request, res: Response): Promise<void> {
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoices = await invoiceService.getInvoices(ecdId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
@@ -23,10 +25,11 @@ export async function getInvoices(req: Request, res: Response): Promise<void> {
/**
* Einzelne Rechnung abrufen
*/
export async function getInvoice(req: Request, res: Response): Promise<void> {
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
if (!invoice) {
@@ -50,9 +53,10 @@ export async function getInvoice(req: Request, res: Response): Promise<void> {
/**
* Neue Rechnung hinzufügen
*/
export async function addInvoice(req: Request, res: Response): Promise<void> {
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
if (!invoiceDate || !invoiceType) {
@@ -89,10 +93,11 @@ export async function addInvoice(req: Request, res: Response): Promise<void> {
/**
* Rechnung aktualisieren
*/
export async function updateInvoice(req: Request, res: Response): Promise<void> {
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
@@ -121,10 +126,11 @@ export async function updateInvoice(req: Request, res: Response): Promise<void>
/**
* Rechnung löschen
*/
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
await invoiceService.deleteInvoice(ecdId, invoiceId);
@@ -146,9 +152,10 @@ export async function deleteInvoice(req: Request, res: Response): Promise<void>
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const invoices = await invoiceService.getInvoicesByContract(contractId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
@@ -156,9 +163,10 @@ export async function getInvoicesByContract(req: Request, res: Response): Promis
}
}
export async function addInvoiceByContract(req: Request, res: Response): Promise<void> {
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
invoiceDate: new Date(invoiceDate),
@@ -0,0 +1,209 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import * as appSettingService from '../services/appSetting.service.js';
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
/**
* GET /api/monitoring/events
* Liste der Security-Events mit Filter + Pagination.
*/
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const page = parseInt((req.query.page as string) || '1');
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
const type = req.query.type as SecurityEventType | undefined;
const severity = req.query.severity as SecuritySeverity | undefined;
const search = req.query.search as string | undefined;
const since = req.query.since as string | undefined;
const ip = req.query.ip as string | undefined;
const where: any = {};
if (type) where.type = type;
if (severity) where.severity = severity;
if (ip) where.ipAddress = ip;
if (since) where.createdAt = { gte: new Date(since) };
if (search) {
where.OR = [
{ message: { contains: search } },
{ userEmail: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [events, total, byType, bySeverity] = await Promise.all([
prisma.securityEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: (page - 1) * limit,
}),
prisma.securityEvent.count({ where }),
prisma.securityEvent.groupBy({
by: ['type'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
prisma.securityEvent.groupBy({
by: ['severity'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
]);
res.json({
success: true,
data: events,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
stats: {
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
},
} as any);
} catch (error) {
console.error('listEvents error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
}
}
/**
* GET /api/monitoring/settings
*/
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
res.json({
success: true,
data: {
alertEmail: alertEmail || '',
digestEnabled,
lastDigestAt: lastDigest || null,
},
} as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
}
}
/**
* PUT /api/monitoring/settings
*/
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
try {
const { alertEmail, digestEnabled } = req.body || {};
if (typeof alertEmail === 'string') {
// Email-Validierung minimal: muss @ enthalten oder leer sein
if (alertEmail !== '' && !alertEmail.includes('@')) {
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
return;
}
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
}
if (typeof digestEnabled === 'boolean') {
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
}
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
}
}
/**
* POST /api/monitoring/test-alert
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
*/
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) {
res.status(400).json({
success: false,
error: 'Keine Alert-E-Mail konfiguriert',
} as ApiResponse);
return;
}
const result = await sendAlertEmail(alertEmail, {
subject: '[OpenCRM] Test-Alert',
events: [{
type: 'SUSPICIOUS' as any,
severity: 'INFO' as any,
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
createdAt: new Date(),
} as any],
isDigest: false,
});
if (result.success) {
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
} else {
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
}
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
} as ApiResponse);
}
}
/**
* DELETE /api/monitoring/events
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
* Audit-Trail erhalten bleibt.
*/
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const olderThanDays = req.query.olderThanDays
? parseInt(req.query.olderThanDays as string)
: undefined;
const where: any = {};
if (olderThanDays && olderThanDays > 0) {
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
where.createdAt = { lt: cutoff };
}
const result = await prisma.securityEvent.deleteMany({ where });
// Audit-Spur: Wer hat geleert
const user = (req as any).user;
await prisma.securityEvent.create({
data: {
type: 'PERMISSION_CHANGED',
severity: 'INFO',
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
userId: user?.userId || null,
userEmail: user?.email || null,
ipAddress: req.ip || 'unknown',
endpoint: 'DELETE /api/monitoring/events',
},
});
res.json({
success: true,
message: `${result.count} Events gelöscht`,
data: { deletedCount: result.count },
} as any);
} catch (error) {
console.error('clearEvents error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
}
}
/**
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
*/
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
try {
const result = await sendDigest({ force: true });
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
} as ApiResponse);
}
}
@@ -2,6 +2,7 @@ import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getTemplates(req: AuthRequest, res: Response) {
try {
@@ -149,6 +150,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs });
} catch (error) {
@@ -160,6 +162,7 @@ export async function generatePdf(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Extras aus Body (POST) oder Query-Parametern (GET)
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
+14 -1
View File
@@ -18,7 +18,20 @@ export async function getProviders(req: Request, res: Response): Promise<void> {
export async function getProvider(req: Request, res: Response): Promise<void> {
try {
const provider = await providerService.getProviderById(parseInt(req.params.id));
// `req.params.id` ist Pfad-Segment bei /api/providers/email landet
// hier der String "email", den parseInt zu NaN macht. Ohne Validierung
// fuhr Prisma dann gegen `WHERE id = NaN` und warf 500.
// Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes
// wie /api/providers/<id>/tariffs greifen weiter wie gehabt.
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(404).json({
success: false,
error: 'Anbieter nicht gefunden',
} as ApiResponse);
return;
}
const provider = await providerService.getProviderById(id);
if (!provider) {
res.status(404).json({
success: false,
@@ -0,0 +1,155 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
import { logChange } from '../services/audit.service.js';
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
type ActiveLock = {
ipAddress: string;
email: string | null; // null = Passwort-Reset oder Login ohne Email
lastHit: Date;
hitCount: number;
lastEndpoint: string | null;
limiters: string[]; // 'login' / 'password-reset'
};
function lockKey(ip: string, email: string | null): string {
return `${ip}|${(email || '').toLowerCase()}`;
}
/**
* Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener
* Bucket im Limiter Reset gilt exakt für dieses Paar.
*/
export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise<void> {
try {
const since = new Date(Date.now() - LOGIN_WINDOW_MS);
const events = await prisma.securityEvent.findMany({
where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
select: {
ipAddress: true,
userEmail: true,
endpoint: true,
createdAt: true,
details: true,
},
});
const byKey = new Map<string, ActiveLock>();
for (const ev of events) {
const ip = ev.ipAddress || 'unknown';
const email = (ev.userEmail || '').toLowerCase() || null;
const limiter = (ev.details as any)?.limiter ?? 'unknown';
const key = lockKey(ip, email);
const existing = byKey.get(key);
if (existing) {
existing.hitCount += 1;
if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter);
} else {
byKey.set(key, {
ipAddress: ip,
email,
lastHit: ev.createdAt,
hitCount: 1,
lastEndpoint: ev.endpoint,
limiters: [limiter],
});
}
}
// Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs
// nutzen resourceId = "<ip>|<email>" (gleicher Schlüssel wie Bucket).
const candidates = Array.from(byKey.entries()).map(([k, e]) => ({
mapKey: k,
resourceId: k,
lastHit: e.lastHit,
}));
if (candidates.length > 0) {
const recentResets = await prisma.auditLog.findMany({
where: {
resourceType: 'RateLimit',
resourceId: { in: candidates.map((c) => c.resourceId) },
createdAt: { gte: since },
},
select: { resourceId: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
const resetMap = new Map<string, Date>();
for (const r of recentResets) {
if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt);
}
for (const c of candidates) {
const reset = resetMap.get(c.resourceId);
if (reset && reset >= c.lastHit) byKey.delete(c.mapKey);
}
}
const list = Array.from(byKey.values()).sort(
(a, b) => b.lastHit.getTime() - a.lastHit.getTime(),
);
res.json({ success: true, data: list } as ApiResponse);
} catch (error) {
console.error('getActiveRateLimits error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der aktiven Rate-Limits',
} as ApiResponse);
}
}
/**
* Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten
* + optional email. Bei fehlender Email wird `<ip>|<no-email>` reseted
* (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der
* IP-only-Key (alter Stil) zusätzlich reseted.
*/
export async function resetRateLimit(req: AuthRequest, res: Response): Promise<void> {
try {
const ip = (req.body?.ipAddress || '').toString().trim();
const email = (req.body?.email || '').toString().trim().toLowerCase();
if (!ip) {
res.status(400).json({
success: false,
error: 'IP-Adresse erforderlich',
} as ApiResponse);
return;
}
// Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|<no-email>`
const loginKey = email ? `${ip}|${email}` : `${ip}|<no-email>`;
await (loginRateLimiter as any).resetKey?.(loginKey);
// Passwort-Reset-Limit ist (noch) IP-only auch zurücksetzen
await (passwordResetRateLimiter as any).resetKey?.(ip);
// Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den
// Eintrag aus der Anzeige filtern kann.
const audited = `${ip}|${email || ''}`;
await logChange({
req,
action: 'UPDATE',
resourceType: 'RateLimit',
resourceId: audited,
label: email
? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben`
: `Rate-Limit für IP ${ip} manuell freigegeben`,
});
res.json({
success: true,
message: email
? `Rate-Limit für (${ip}, ${email}) freigegeben`
: `Rate-Limit für ${ip} freigegeben`,
} as ApiResponse);
} catch (error) {
console.error('resetRateLimit error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Zurücksetzen des Rate-Limits',
} as ApiResponse);
}
}
@@ -1,11 +1,18 @@
import { Request, Response } from 'express';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
// requireCustomerAccess in der Route greift nicht ausreichend:
// Portal-User haben `customers:read` (für eigene Daten) und werden
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
// (MEDIUM 31.2) IDOR auf fremde IMAP-Konten. Hier daher der
// explizite Per-Customer-Check analog zum POST-Handler.
if (!(await canAccessCustomer(req, res, customerId))) return;
const includeInactive = req.query.includeInactive === 'true';
const emails = await stressfreiEmailService.getEmailsByCustomerId(customerId, includeInactive);
res.json({ success: true, data: emails } as ApiResponse);
@@ -17,9 +24,12 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
}
}
export async function getEmail(req: Request, res: Response): Promise<void> {
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.getEmailById(emailId);
if (!email) {
res.status(404).json({
success: false,
@@ -27,7 +37,13 @@ export async function getEmail(req: Request, res: Response): Promise<void> {
} as ApiResponse);
return;
}
res.json({ success: true, data: email } as ApiResponse);
// Sensibles Feld emailPasswordEncrypted nie an Portal-Kunden geben
const sanitized: any = { ...email };
if (req.user?.isCustomerPortal) {
delete sanitized.emailPasswordEncrypted;
}
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -58,9 +74,11 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
}
}
export async function updateEmail(req: Request, res: Response): Promise<void> {
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const email = await stressfreiEmailService.updateEmail(parseInt(req.params.id), req.body);
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
await logChange({
req, action: 'UPDATE', resourceType: 'StressfreiEmail',
resourceId: email.id.toString(),
@@ -75,9 +93,10 @@ export async function updateEmail(req: Request, res: Response): Promise<void> {
}
}
export async function deleteEmail(req: Request, res: Response): Promise<void> {
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
await stressfreiEmailService.deleteEmail(emailId);
await logChange({
req, action: 'DELETE', resourceType: 'StressfreiEmail',
@@ -93,9 +112,50 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
}
}
export async function resetPassword(req: Request, res: Response): Promise<void> {
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
try {
const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id));
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
if (!result.success) {
res.status(400).json({ success: false, error: result.error } as ApiResponse);
return;
}
const labelParts = [`Weiterleitungen: ${(result.forwardTargets || []).join(', ')}`];
if (result.passwordReset) labelParts.push('Mailbox-Passwort am Provider neu gesetzt');
await logChange({
req,
action: 'UPDATE',
resourceType: 'StressfreiEmail',
resourceId: emailId.toString(),
label: `Stressfrei-Sync: ${labelParts.join(' | ')}`,
});
res.json({
success: true,
data: {
forwardTargets: result.forwardTargets,
customerEmail: result.customerEmail,
passwordReset: result.passwordReset,
},
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
} as ApiResponse);
}
}
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
if (!result.success) {
res.status(400).json({
success: false,
+108 -5
View File
@@ -3,6 +3,8 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -49,7 +51,25 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
const user = await userService.createUser(req.body);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserCreate(req.body) as any;
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
// (Pentest 29.4 SMTP-Header-Injection).
if (!isValidEmail(data?.email) || !data?.email) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
if (data?.password) {
const c = validatePasswordComplexity(data.password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
}
const user = await userService.createUser(data);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -67,18 +87,62 @@ export async function createUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const data = req.body;
// `permissions` und `password` darf der generische Update nicht
// entgegennehmen. Vorher landeten sie auf dem Floor (Whitelist-Drop),
// der Caller bekam aber 200 zurück und glaubte fälschlich, die Werte
// wären übernommen worden. Stattdessen sofort 400, damit Tooling /
// Client den Fehler sieht. (Pentest 2026-05-20)
// - permissions kommen aus Rollen (PUT roleIds bzw. die DSGVO-/
// Developer-Checkboxen) und können nicht direkt am User hängen.
// - password wird über POST /users/:id/password gesetzt
// (eigene Komplexitäts-Validierung + Audit-Trail).
const body = req.body || {};
const forbidden = ['permissions', 'password', 'passwordHash'];
const offending = forbidden.filter((k) => k in body);
if (offending.length > 0) {
res.status(400).json({
success: false,
error: `Felder nicht erlaubt: ${offending.join(', ')}. ` +
(offending.includes('permissions')
? 'Permissions werden über roleIds / hasGdprAccess / hasDeveloperAccess gesteuert. '
: '') +
(offending.includes('password') || offending.includes('passwordHash')
? `Passwort über POST /users/${userId}/password setzen.`
: ''),
} as ApiResponse);
return;
}
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// Vorherigen Stand laden für Audit
const before = await prisma.user.findUnique({ where: { id: userId } });
// Vorherigen Stand laden für Audit inkl. Rollen, damit hasGdprAccess /
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
const beforeUser = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { role: true } } },
});
const before = beforeUser
? {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
}
: null;
const user = await userService.updateUser(userId, data);
const user = await userService.updateUser(userId, data as any);
if (user) {
// Audit: Geänderte Felder ermitteln und loggen
if (before) {
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -118,6 +182,45 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
}
}
// Admin setzt das Passwort eines anderen Users zurück. Separat vom
// generischen Update damit der Vorgang explizit auditiert wird und nicht
// versehentlich über Mass-Assignment passieren kann.
// Pentest Runde 12 (2026-05-18) MITTEL.
export async function setUserPassword(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
const { password } = req.body || {};
if (!password || typeof password !== 'string') {
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
return;
}
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
if (!c.ok) {
res.status(400).json({
success: false,
error: 'Passwort erfüllt Mindestanforderungen nicht: ' + c.errors.join(', '),
} as ApiResponse);
return;
}
const user = await userService.updateUser(userId, { password } as any);
if (!user) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return;
}
await logChange({
req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(),
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
}
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
+344 -14
View File
@@ -1,7 +1,34 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
// .env-Dateien laden Root-.env hat Priorität (zentrale Konfiguration für
// Dev + Docker), backend/.env als Legacy-Fallback. Im Container sind
// Variablen schon via env_file/environment gesetzt dotenv überschreibt
// existierende process.env-Werte nicht.
// __dirname zeigt auf src/ (dev via tsx) oder dist/ (build). In beiden Fällen
// liegt Root /.env zwei Ebenen darüber.
//
// dotenvExpand löst ${VAR}-Substitution auf, sodass z.B.
// DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
// dynamisch aus den Komponenten zusammengebaut wird (kein Doppel-Pflegen).
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../../.env') }));
dotenvExpand.expand(dotenv.config({ path: path.resolve(__dirname, '../.env') }));
dotenvExpand.expand(dotenv.config());
// Fallback: wenn DATABASE_URL nicht direkt gesetzt ist (oder Substitution
// nicht funktioniert hat), aus den DB_*-Komponenten zusammenbauen.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'localhost';
const port = process.env.DB_PORT || '3306';
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
}
import authRoutes from './routes/auth.routes.js';
import customerRoutes from './routes/customer.routes.js';
@@ -32,24 +59,278 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
dotenv.config();
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
console.error(' Generiere mit: openssl rand -hex 64');
process.exit(1);
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)');
console.error(' Generiere mit: openssl rand -hex 32');
process.exit(1);
}
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Trust-Proxy-Konfiguration für `req.ip` und `X-Forwarded-For`.
//
// Zwei Szenarien:
// 1) **HTTPS_ENABLED=true** (Produktion mit vorgelagertem TLS-Proxy auf
// EIGENER Box, z.B. Nginx Proxy Manager): `trust proxy = 1` vertraut
// genau einem Hop → req.ip = echter Client (nicht der Proxy).
// Voraussetzung: Backend ist NICHT direkt aus dem Internet erreichbar,
// sonst könnte ein Direkt-Connect X-Forwarded-For faken und den
// Rate-Limiter / Security-Monitor umgehen. Bei NPM-Setup ist das
// durch das Docker-Network + nicht-veröffentlichten Backend-Port
// gewährleistet.
// 2) **HTTPS_ENABLED=false** (lokales Dev oder direkter http://ip:port-
// Zugriff): `loopback` reicht kein vertrauenswürdiger Hop davor.
//
// Vor dem Fix stand das auf `'loopback'` was im Produktiv-NPM-Setup
// IMMER die Proxy-IP statt der Client-IP lieferte → Rate-Limit und
// IDOR-Threshold-Detection sahen alle Angriffe als von „einem" Client.
const trustProxyValue = process.env.HTTPS_ENABLED === 'true' ? 1 : 'loopback';
app.set('trust proxy', trustProxyValue);
// ==================== SECURITY MIDDLEWARE ====================
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, CSP, ...)
//
// CSP ist konservativ aber SPA-tauglich:
// - script-src 'self' → keine externen Skripte, keine inline-Scripts
// (Vite baut Module-Skripte zu separaten Files,
// die sind 'self')
// - style-src 'self' 'unsafe-inline' → Tailwind/inline-Styles brauchen das
// (sicheres Trade-off; XSS via CSS ist
// marginal vs Lock-Out gegen die UI)
// - img-src self/data/blob → base64-Avatare + blob-URLs für PDFs/Downloads
// - font-src self/data → eingebettete Fonts
// - connect-src 'self' → API + WebSocket nur zur eigenen Origin
// - frame-ancestors 'none' → Clickjacking-Schutz (ersetzt X-Frame-Options)
// - object-src 'none' → keine Flash/<object>/<embed>-Embeds
// - base-uri 'self' → keine <base>-Hijacking-Tricks
// - form-action 'self' → POST-Targets nur auf eigene Origin
// Permissions-Policy: schaltet Browser-APIs aus, die wir nicht brauchen.
// Verhindert, dass eingeschleustes JS Zugriff auf Kamera/Mikro/GPS/Payment etc.
// bekommt. clipboard-write ist 'self' für die CopyButton-Komponenten,
// fullscreen 'self' falls jemand mal eine Vorschau in Vollbild öffnet.
app.use((_req, res, next) => {
res.setHeader(
'Permissions-Policy',
[
'accelerometer=()',
'ambient-light-sensor=()',
'autoplay=()',
'battery=()',
'camera=()',
'clipboard-read=()',
'clipboard-write=(self)',
'cross-origin-isolated=()',
'display-capture=()',
'encrypted-media=()',
'fullscreen=(self)',
'geolocation=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'magnetometer=()',
'microphone=()',
'midi=()',
'payment=()',
'picture-in-picture=()',
'publickey-credentials-get=()',
'screen-wake-lock=()',
'sync-xhr=()',
'usb=()',
'web-share=()',
'xr-spatial-tracking=()',
].join(', '),
);
next();
});
// HTTPS-only-Header (HSTS + upgrade-insecure-requests) nur setzen, wenn
// wirklich TLS davor läuft sonst sperrt sich die App auf direkt-via-IP-
// Deployments (Browser versucht /assets/* via https zu laden → SSL-Error).
// Aktivieren mit HTTPS_ENABLED=true in der .env, sobald ein TLS-Proxy
// (Caddy/Traefik/Nginx) vor OpenCRM steht.
const httpsEnabled = process.env.HTTPS_ENABLED === 'true';
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'data:'],
'connect-src': ["'self'"],
// Explizit gesetzt obwohl Fallback auf default-src/script-src greift
// ZAP markiert sonst "No-Fallback-Direktiven" als CSP-Lücke.
'worker-src': ["'self'"],
'manifest-src': ["'self'"],
'media-src': ["'self'"],
// 'self': eigene App darf eigene Resourcen in iframes embeden (z.B. die
// annotierte PDF-Vorschau in der Auftragsvorlagen-Konfiguration).
// 'none' würde sogar same-origin blocken und damit die UI brechen.
// Externe Sites bleiben weiterhin gesperrt.
'frame-ancestors': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
// useDefaults bringt 'upgrade-insecure-requests' selbst mit explizit
// auf null setzen entfernt es aus dem Header (helmet-API).
'upgrade-insecure-requests': httpsEnabled ? [] : null,
},
},
// HSTS NIE in Helmet senden der vorgelagerte TLS-Reverse-Proxy
// (Nginx Proxy Manager) macht das bereits. Doppelter Header verletzt
// RFC 6797 (Multiple Header Entries) und wird von ZAP angemahnt.
// HTTPS_ENABLED-Flag bleibt für upgrade-insecure-requests (CSP) relevant.
strictTransportSecurity: false,
crossOriginResourcePolicy: { policy: 'same-site' },
}),
);
// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben.
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
: process.env.NODE_ENV === 'production'
? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert)
: true; // Dev: alles erlauben
app.use(
cors({
origin: corsOrigins,
credentials: true,
}),
);
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
app.use(express.json({ limit: '5mb' }));
// Cookie-Parser: wird für den httpOnly-Refresh-Token-Cookie gebraucht
// (POST /api/auth/refresh liest ihn aus req.cookies).
app.use(cookieParser());
// Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware);
app.use(auditMiddleware);
// Statische Dateien für Uploads
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
// `/api/uploads/*` express.static).
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
// und prüft canAccessCustomer/canAccessContract damit kann ein Portal-Kunde
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
//
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
// Request über denselben Owner-Check (kein freier static-Handler mehr).
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> egal ob als
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
app.get('/api/files/download', authenticate as any, downloadFile as any);
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
// (siehe express.static mit immutable weiter unten).
app.use('/api', (_req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
// Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster
// in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen.
// So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe
// Prisma-Internals wie "Invalid `prisma.user.update()` invocation:
// Record to update not found" (Pentest Runde 11 M3). Der Original-Text
// landet weiterhin im Server-Log.
const ORM_LEAK_PATTERNS: RegExp[] = [
/Invalid `prisma\./i,
/PrismaClient/i,
/^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
/at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
// JS-Runtime-Fehler Pentest Runde 12 (2026-05-18): "Cannot read
// properties of undefined (reading 'substring')" leakte aus POST
// /contracts. Solche Texte verraten Implementierungs-Details.
/^TypeError\b/i,
/^ReferenceError\b/i,
/^SyntaxError\b/i,
/^RangeError\b/i,
/Cannot read propert(y|ies) of (undefined|null)/i,
/is not a function/i,
/is not defined$/i,
];
function sanitizeErrorString(s: string): string {
if (!s) return s;
for (const re of ORM_LEAK_PATTERNS) {
if (re.test(s)) {
console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300));
return 'Operation fehlgeschlagen';
}
}
return s;
}
app.use('/api', (_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (body && typeof body === 'object') {
if (typeof body.error === 'string') {
body.error = sanitizeErrorString(body.error);
}
if (typeof body.details === 'string') {
body.details = sanitizeErrorString(body.details);
}
}
return originalJson(body);
};
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
//
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$`
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
@@ -83,9 +364,16 @@ app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check
app.get('/api/health', (req, res) => {
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
// "ok" + Timestamp, keine Version, kein DB-Status, kein Hostname damit
// auch unauth Caller keine internen Infos einsammeln können. Pentest
// 2026-05-20 (INFO): kein Auth → akzeptiert, Antwort liefert nichts
// Sensibles.
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
@@ -93,8 +381,29 @@ app.get('/api/health', (req, res) => {
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(process.cwd(), 'public');
// Serve static files
app.use(express.static(publicPath));
// Vite-Build-Assets (z.B. /assets/index-abc123.js) haben einen Content-Hash
// im Dateinamen das Image ist also versioniert. Daher kann der Browser
// sie für ein Jahr aggressiv cachen und muss nicht revalidieren.
app.use(
'/assets',
express.static(path.join(publicPath, 'assets'), {
maxAge: '1y',
immutable: true,
}),
);
// Rest des Frontends (index.html selbst, vite.svg, robots.txt, sitemap.xml).
// express.static findet index.html bei GET /, deshalb MUSS hier das gleiche
// no-store-Verhalten greifen wie im SPA-Fallback weiter unten sonst
// serviert der erste Static-Handler / mit dem express-Default `max-age=0`,
// bevor der Fallback überhaupt greift, und der Browser cached die alte SPA.
app.use(
express.static(publicPath, {
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate');
},
}),
);
// SPA fallback: serve index.html for all non-API routes
app.get('*', (req, res, next) => {
@@ -102,16 +411,37 @@ if (process.env.NODE_ENV === 'production') {
if (req.path.startsWith('/api')) {
return next();
}
// SPA-Wurzel darf NIE gecached werden sonst sieht der Browser nach einem
// Deploy weiterhin die alte index.html mit alten Asset-Hashes.
res.setHeader('Cache-Control', 'no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Error handling
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
// kaschiert und landen als "Interner Serverfehler" beim User.
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
let message = 'Interner Serverfehler';
if (status === 413) message = 'Anfrage zu groß';
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
message = 'Ungültiges JSON';
}
res.status(status).json({ success: false, error: message });
});
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
startBirthdayScheduler();
startContractStatusScheduler();
startSecurityMonitorScheduler();
});
+16
View File
@@ -1,6 +1,22 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { setBeforeValues, setAfterValues } from '../middleware/auditContext.js';
// DATABASE_URL aus DB_*-Komponenten bauen, falls nicht explizit gesetzt.
// Der entrypoint.sh macht das ebenfalls (für den Server-Start). Aber bei
// `docker exec opencrm-app npx tsx prisma/<script>.ts` läuft eine neue
// Shell ohne diese exportierte Variable die DB_*-Vars sind aus dem
// docker-compose.yml vererbt, DATABASE_URL aber nicht. Damit alle
// Wartungsskripte (reset-admin-password, cleanup-xss-...) und Server
// dieselbe Logik nutzen, machen wir es einmal zentral hier.
if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD && process.env.DB_NAME) {
const u = encodeURIComponent(process.env.DB_USER);
const p = encodeURIComponent(process.env.DB_PASSWORD);
const h = process.env.DB_HOST || 'db';
const port = process.env.DB_PORT || '3306';
const n = process.env.DB_NAME;
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${n}`;
}
// Modelle die für Before/After-Tracking relevant sind
const AUDITED_MODELS = [
'Customer',
+91 -21
View File
@@ -2,6 +2,7 @@ import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js';
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
export async function authenticate(
req: AuthRequest,
@@ -12,12 +13,15 @@ export async function authenticate(
// Token aus Header oder Query-Parameter (für Downloads)
let token: string | null = null;
let tokenSource: 'header' | 'query' | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
tokenSource = 'header';
} else if (req.query.token && typeof req.query.token === 'string') {
// Fallback für Downloads: Token als Query-Parameter
token = req.query.token;
tokenSource = 'query';
}
if (!token) {
@@ -26,27 +30,49 @@ export async function authenticate(
}
try {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || 'fallback-secret'
) as JwtPayload;
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
}) as JwtPayload & { type?: string };
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
// Defense-in-Depth: Refresh-Tokens haben `type: 'refresh'` und dürfen
// NICHT für normale API-Calls verwendet werden nur am /api/auth/refresh-
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
// zwangsabgemeldet werden.
if (decoded.type === 'refresh') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
// werden (Pentest Runde 7 NIEDRIG, Token-in-URL-Defense).
if (decoded.type === 'download' && tokenSource !== 'query') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
return;
}
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
if (decoded.userId && decoded.iat) {
// Mitarbeiter-Login
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { tokenInvalidatedAt: true, isActive: true },
});
// Benutzer nicht gefunden oder deaktiviert
if (!user || !user.isActive) {
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
return;
}
// Token wurde vor der Invalidierung ausgestellt
if (user.tokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden
const tokenIssuedAt = decoded.iat * 1000;
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
@@ -55,11 +81,42 @@ export async function authenticate(
return;
}
}
} else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) {
// Portal-Kunden-Login: gleiche Prüfung
const customer = await prisma.customer.findUnique({
where: { id: decoded.customerId },
select: { portalTokenInvalidatedAt: true, portalEnabled: true },
});
if (!customer || !customer.portalEnabled) {
res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' });
return;
}
if (customer.portalTokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000;
if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.',
});
return;
}
}
}
req.user = decoded;
next();
} catch {
} catch (err) {
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
endpoint: `${req.method} ${req.path}`,
});
res.status(401).json({ success: false, error: 'Ungültiger Token' });
}
}
@@ -101,28 +158,41 @@ export function requireCustomerAccess(
return;
}
// WICHTIG: erst die isCustomerPortal-Prüfung, DANN erst die Perm-Prüfung.
// Portal-User bekommen `customers:read` im JWT (für eigene Daten); ohne
// den Portal-Check vorne weg short-circuited die alte Logik auf der
// Perm und ließ Portal-User auf fremde customerId zugreifen.
// Pentest 2026-05-24 (MEDIUM 31.2 IDOR auf /api/customers/:id/
// stressfrei-emails). Auch andere Routes mit dem gleichen Middleware-
// Pattern wären betroffen gewesen.
const userPermissions = req.user.permissions || [];
// Admins and employees can access all customers
if (
userPermissions.includes('customers:read') ||
userPermissions.includes('customers:update')
) {
next();
return;
}
// Customers can only access their own data + represented customers
const isPortal = !!(req.user as any).isCustomerPortal;
const customerId = parseInt(req.params.customerId || req.params.id);
if (isPortal) {
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diese Kundendaten',
});
return;
}
// Mitarbeiter/Admin: customers:read oder customers:update reicht
if (
userPermissions.includes('customers:read') ||
userPermissions.includes('customers:update')
) {
next();
return;
}
res.status(403).json({
success: false,
+106
View File
@@ -0,0 +1,106 @@
/**
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
*
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
* SecurityEvent (RATE_LIMIT_HIT) damit der Monitoring-View und das
* Alert-System sehen, wenn jemand auf die Tür hämmert.
*/
import rateLimit from 'express-rate-limit';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
return (req: any, _res: any) => {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'RATE_LIMIT_HIT',
severity,
message: `Rate-Limit überschritten: ${label}`,
ipAddress: ctx.ipAddress,
userEmail: req.body?.email,
endpoint: ctx.endpoint,
details: { limiter: label },
});
};
}
/**
* Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple.
*
* Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein:
* - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder
* 10 freie Versuche gegen den gleichen Account.
* - Email allein erzeugt False-Positives (Familie hinter NAT: Max
* vertippt sich → Nina kommt von gleicher IP nicht mehr rein) und
* macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts
* aus, indem er von beliebigen IPs falsche PWs gegen sie probiert).
* - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von
* gleicher IP schon. Max von einer anderen IP auch, solange er das
* richtige PW hat ihre eigene Spur in den Buckets ist sauber.
*
* keyGenerator → `${ip}|${email-lowercase}`. Bei fehlender Email
* (z.B. komplett leerer Body) Fallback nur auf IP, damit kein
* Single-Shared-Bucket entsteht.
*/
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.',
},
skipSuccessfulRequests: true,
keyGenerator: (req): string => {
const email = (req.body?.email || '').toString().trim().toLowerCase();
const ip = req.ip || 'unknown';
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
},
handler: (req, res, _next, options) => {
onLimitReached('login', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message);
},
});
/**
* Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP.
* Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links.
*/
export const passwordResetRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 Stunde
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
},
handler: (req, res, _next, options) => {
onLimitReached('password-reset', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.message);
},
});
/**
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
* aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und
* den Service durch Audit-Log-Spam + Mail-Versand belasten.
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
* für legitime Kunden weit aus.
*/
export const publicConsentRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.',
},
handler: (req, res, _next, options) => {
onLimitReached('public-consent', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.message);
},
});
+29
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern)
@@ -100,4 +101,32 @@ router.post(
backupController.factoryReset
);
// Backup-Operations-Log: Liste (ohne fullLog) + Detail
router.get(
'/backup-logs',
authenticate,
requirePermission('settings:update'),
backupController.listBackupLogs
);
router.get(
'/backup-logs/:id',
authenticate,
requirePermission('settings:update'),
backupController.getBackupLogDetail
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
authenticate,
requirePermission('settings:read'),
rateLimitAdminController.getActiveRateLimits,
);
router.post(
'/rate-limits/reset',
authenticate,
requirePermission('settings:update'),
rateLimitAdminController.resetRateLimit,
);
export default router;
+20 -2
View File
@@ -1,12 +1,30 @@
import { Router } from 'express';
import * as authController from '../controllers/auth.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
const router = Router();
router.post('/login', authController.login);
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich
// `nina` von derselben IP einloggen, auch wenn `max` dort gerade
// 10x vergeigt hat und umgekehrt darf `max` von einer anderen IP
// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt
// hat (Pentest 2026-05-18 Szenario).
router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/refresh', authController.refresh);
router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
// Passwort-Reset-Flow
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
// Force-Change-Password nach Einmalpasswort-Login (Kundenportal)
router.post('/change-initial-portal-password', authenticate, authController.changeInitialPortalPassword);
// Kurzlebiger Download-Token (60s) für ?token=-Aufrufe (PDF/Export-Window)
router.post('/download-token', authenticate, authController.createDownloadToken);
export default router;
+4
View File
@@ -7,6 +7,10 @@ const router = Router();
// Admin: Kommende und vergangene Geburtstage
router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays);
// Admin: Gruß-Marker zurücksetzen + Gruß senden
router.post('/:customerId/reset', authenticate, requirePermission('customers:update'), birthdayController.resetBirthdayGreeting);
router.post('/:customerId/send', authenticate, requirePermission('customers:update'), birthdayController.sendBirthdayGreeting);
// Portal: eigener Geburtstag-Check
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
+9
View File
@@ -203,6 +203,15 @@ router.post(
cachedEmailController.saveAttachmentAsInvoice
);
// Anhang als Vertragsdokument speichern
// POST /api/emails/:id/attachments/:filename/save-as-contract-document { documentType, notes? }
router.post(
'/emails/:id/attachments/:filename/save-as-contract-document',
authenticate,
requirePermission('contracts:update'),
cachedEmailController.saveAttachmentAsContractDocument
);
// ==================== VERTRAGSZUORDNUNG ====================
// E-Mail Vertrag zuordnen
+5 -1
View File
@@ -1,9 +1,13 @@
import { Router } from 'express';
import * as controller from '../controllers/consent-public.controller.js';
import { publicConsentRateLimiter } from '../middleware/rateLimit.js';
const router = Router();
// Öffentliche Routes - KEINE Authentifizierung erforderlich
// Öffentliche Routes - KEINE Authentifizierung erforderlich.
// Rate-Limit gegen DoS siehe publicConsentRateLimiter
// (Pentest 2026-05-20 INFO 28.4).
router.use(publicConsentRateLimiter);
router.get('/:hash', controller.getConsentPage);
router.post('/:hash/grant', controller.grantAllConsents);
router.get('/:hash/pdf', controller.getConsentPdf);
+3
View File
@@ -42,6 +42,9 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr
// Follow-up contract
router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp);
// VVL (Vertragsverlängerung beim selben Anbieter, vollständige Kopie + Datums-Berechnung)
router.post('/:id/renewal', authenticate, requirePermission('contracts:create'), contractController.createRenewal);
// Snooze (Vertrag zurückstellen)
router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract);
+2
View File
@@ -37,6 +37,8 @@ router.get('/:customerId/portal', authenticate, requirePermission('customers:upd
router.put('/:customerId/portal', authenticate, requirePermission('customers:update'), customerController.updatePortalSettings);
router.post('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.setPortalPassword);
router.get('/:customerId/portal/password', authenticate, requirePermission('customers:update'), customerController.getPortalPassword);
router.post('/:customerId/portal/password/generate', authenticate, requirePermission('customers:update'), customerController.generatePortalPassword);
router.post('/:customerId/portal/send-credentials', authenticate, requirePermission('customers:update'), customerController.sendPortalCredentials);
// Representatives (Vertreter)
router.get('/:customerId/representatives', authenticate, requirePermission('customers:read'), customerController.getRepresentatives);
+5 -44
View File
@@ -1,54 +1,15 @@
import { Router, Response } from 'express';
import { Prisma } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
const router = Router();
// Setup-Endpunkt: Erstellt die developer:access Permission und fügt sie der Admin-Rolle hinzu
// Dieser Endpunkt erfordert keine Authentifizierung, da er nur einmalig zum Setup verwendet wird
router.post('/setup', async (req, res: Response) => {
try {
// Create or get the developer:access permission
const developerPerm = await prisma.permission.upsert({
where: { resource_action: { resource: 'developer', action: 'access' } },
update: {},
create: { resource: 'developer', action: 'access' },
});
// Get the Admin role
const adminRole = await prisma.role.findUnique({
where: { name: 'Admin' },
include: { permissions: true },
});
if (!adminRole) {
res.status(404).json({ success: false, error: 'Admin-Rolle nicht gefunden' });
return;
}
// Check if Admin already has this permission
const hasPermission = adminRole.permissions.some(
(rp) => rp.permissionId === developerPerm.id
);
if (!hasPermission) {
await prisma.rolePermission.create({
data: {
roleId: adminRole.id,
permissionId: developerPerm.id,
},
});
res.json({ success: true, message: 'developer:access Permission wurde zur Admin-Rolle hinzugefügt. Bitte neu einloggen!' });
} else {
res.json({ success: true, message: 'Admin-Rolle hat bereits die developer:access Permission' });
}
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Setup' });
}
});
// HINWEIS: Der frühere `POST /setup`-Endpoint wurde entfernt (Pentest Runde 3
// 2026-05-16 KRITISCH). Er war ohne Auth erreichbar und konnte
// `developer:access` an die Admin-Rolle hängen → Privilege-Escalation auf
// volle DB-Kontrolle. Wenn die developer:access-Permission manuell gesetzt
// werden muss, gibt es das CLI-Script `prisma/add-developer-permission.ts`.
// Tabellen-Metadaten mit Beziehungen
const tableMetadata: Record<string, {
@@ -15,7 +15,9 @@ router.delete('/configs/:id', authenticate, requirePermission('settings:update')
// Email Operations
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
@@ -0,0 +1,48 @@
import { Router } from 'express';
import multer from 'multer';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// In-Memory-Upload für die ZIP wird direkt verarbeitet, keine temporäre Datei.
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: (_req, file, cb) => {
const ok =
file.mimetype === 'application/zip' ||
file.mimetype === 'application/x-zip-compressed' ||
file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip
file.originalname.toLowerCase().endsWith('.zip');
if (ok) cb(null, true);
else cb(new Error('Nur ZIP-Dateien sind erlaubt'));
},
limits: { fileSize: 50 * 1024 * 1024 },
});
// Preview (was wäre im Export drin?)
router.get(
'/preview',
authenticate,
requirePermission('settings:read'),
factoryDefaultsController.previewFactoryDefaults,
);
// Export als ZIP-Download
router.get(
'/export',
authenticate,
requirePermission('settings:update'),
factoryDefaultsController.exportFactoryDefaults,
);
// Import aus ZIP (multipart, Feld 'zip')
router.post(
'/import',
authenticate,
requirePermission('settings:update'),
upload.single('zip'),
factoryDefaultsController.importFactoryDefaults,
);
export default router;
+3
View File
@@ -71,6 +71,9 @@ router.put('/website-privacy-policy', requirePermission('gdpr:admin'), gdprContr
// Consent-Link senden
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
// Unterschreibbare Datenschutzerklärung als PDF (Papierform)
router.get('/customer/:customerId/privacy-pdf', requirePermission('customers:read'), gdprController.getSignablePrivacyPdf);
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
router.get('/my-privacy', gdprController.getMyPrivacy);
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as monitoringController from '../controllers/monitoring.controller.js';
const router = Router();
router.use(authenticate);
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
export default router;
+4 -4
View File
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Provider routes
router.get('/', authenticate, providerController.getProviders);
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
router.get('/:id', authenticate, providerController.getProvider);
router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider);
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// Nested tariff routes
router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs);
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
export default router;
@@ -12,4 +12,7 @@ router.delete('/:id', authenticate, requirePermission('customers:delete'), stres
// Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider)
router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword);
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
export default router;
+1 -1
View File
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Standalone tariff routes (for update/delete by tariff id)
router.get('/:id', authenticate, tariffController.getTariff);
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
+43 -1
View File
@@ -6,6 +6,7 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router();
@@ -546,6 +547,7 @@ async function handleContractDocumentUpload(
}
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -563,12 +565,51 @@ async function handleContractDocumentUpload(
}
}
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
let target: Date | null = null;
if (provided) {
const parsed = new Date(provided);
if (!isNaN(parsed.getTime())) target = parsed;
}
if (target) {
updateData[dateField] = target;
} else if (!contract[dateField]) {
updateData[dateField] = new Date();
}
}
// Vertrag in der DB aktualisieren
await prisma.contract.update({
where: { id: contractId },
data: { [fieldName]: relativePath },
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
}
res.json({
success: true,
data: {
@@ -592,6 +633,7 @@ async function handleContractDocumentDelete(
) {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
+2
View File
@@ -10,6 +10,8 @@ router.post('/', authenticate, requirePermission('users:create'), userController
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
// Passwort-Reset durch Admin dedizierter Endpoint (Pentest Runde 12)
router.post('/:id/password', authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -12,6 +13,53 @@ const DEFAULT_SETTINGS: Record<string, string> = {
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
};
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
// anderen Keys mehr an (Pentest Runde 11 (2026-05-18) M1: Mass
// Assignment, "superAdminEmail", "debugMode", "allowedOrigins" landeten
// vorher ungefiltert in der DB).
export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
...Object.keys(DEFAULT_SETTINGS),
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
'monitoringAlertEmail',
'monitoringDigestEnabled',
'monitoringLastDigestAt',
'companyName',
'defaultEmailDomain',
]);
export function isAllowedSettingKey(key: string): boolean {
return ALLOWED_SETTING_KEYS.has(key);
}
// Keys deren Wert legitim HTML enthalten darf (Datenschutz-/Impressum-Editoren
// liefern WYSIWYG-HTML). Alle anderen Plain-Text-Keys (companyName,
// defaultEmailDomain, Schwellenwerte etc.) werden vor dem Persistieren durch
// stripHtml geschickt Pentest 2026-05-19, MEDIUM: <img src=x onerror=alert(1)>
// in companyName landete ungefiltert in der DB und konnte später z.B. in
// E-Mail-Templates oder PDF-Generatoren unescaped landen.
const HTML_ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
'authorizationTemplateHtml',
'imprintHtml',
'privacyPolicyHtml',
'websitePrivacyPolicyHtml',
]);
/**
* Bereinigt den Wert vor dem Speichern: für Plain-Text-Keys werden alle
* HTML-Tags entfernt. Die dedizierten Editor-Keys
* (imprintHtml/privacyPolicyHtml/...) bleiben unverändert, da sie sonst
* den WYSIWYG-Editor unbenutzbar machen würden sie werden über
* dedizierte /api/gdpr-Endpoints gepflegt.
*/
export function sanitizeSettingValue(key: string, value: string): string {
if (HTML_ALLOWED_SETTING_KEYS.has(key)) return value;
const stripped = stripHtml(value);
return typeof stripped === 'string' ? stripped : String(stripped);
}
export async function getSetting(key: string): Promise<string | null> {
const setting = await prisma.appSetting.findUnique({
where: { key },
+7
View File
@@ -112,6 +112,13 @@ function determineSensitivity(resourceType: string): AuditSensitivity {
Authentication: 'CRITICAL',
BankCard: 'CRITICAL',
IdentityDocument: 'CRITICAL',
// Klartext-Passwort-Reads jeder Decrypt-Vorgang muss nachvollziehbar sein
PortalPassword: 'CRITICAL',
ContractPassword: 'CRITICAL',
SimCardCredentials: 'CRITICAL',
InternetCredentials: 'CRITICAL',
SipCredentials: 'CRITICAL',
MailboxCredentials: 'CRITICAL',
// HIGH
Customer: 'HIGH',
User: 'HIGH',
+523 -15
View File
@@ -1,8 +1,81 @@
import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import { getAuthorizedCustomerIds } from './authorization.service.js';
// Token-Lifetimes
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
// - Refresh-Token: lang, im httpOnly-Cookie → kein JS-Zugriff
const ACCESS_TOKEN_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '15m') as jwt.SignOptions['expiresIn'];
const REFRESH_TOKEN_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'];
// Helper: signiert ein Access- bzw. Refresh-JWT mit dem `type`-Claim als
// Unterscheidung. Der Refresh-Token landet im httpOnly-Cookie und wird beim
// /auth/refresh-Endpoint geprüft, der dann einen neuen Access ausgibt.
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET as string, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function signRefreshToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
});
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
// abgerufen, wenn ein Endpoint per `?token=` aufgerufen werden muss
// (z.B. PDF-iframe, Audit-Export-Window). Selbst wenn dieser Token in
// nginx-Access-Logs oder der Browser-History landet, ist er nach
// 60 Sekunden wertlos. Pentest Runde 7 (2026-05-17) NIEDRIG.
export function signDownloadToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'download' }, process.env.JWT_SECRET as string, {
expiresIn: '60s',
});
}
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;
// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
// dem Timing-Angleich.
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';
/**
* Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
* Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
* aus der Installation) werden so lazy auf Cost 12 migriert damit sich die
* Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
*/
async function maybeUpgradePasswordHash(
table: 'user' | 'customer',
id: number,
plaintextPassword: string,
currentHash: string,
): Promise<void> {
const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
const currentCost = match ? parseInt(match[1], 10) : 0;
if (currentCost === BCRYPT_COST) return;
try {
const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
if (table === 'user') {
await prisma.user.update({ where: { id }, data: { password: newHash } });
} else {
await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
}
} catch (err) {
// Nicht kritisch Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
}
}
// Mitarbeiter-Login
export async function login(email: string, password: string) {
@@ -26,6 +99,9 @@ export async function login(email: string, password: string) {
});
if (!user || !user.isActive) {
// Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
// nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten');
}
@@ -34,6 +110,10 @@ export async function login(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
// Async, nicht blockierend für die Response.
maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});
// Collect all permissions from all roles
const permissions = new Set<string>();
for (const userRole of user.roles) {
@@ -52,12 +132,12 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
return {
token,
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
@@ -100,6 +180,8 @@ export async function customerLogin(email: string, password: string) {
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
// Timing-Attack-Schutz (siehe login())
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten');
}
@@ -110,16 +192,42 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Einmalpasswort-Check: wurde es per "Zugangsdaten versenden" verschickt?
// Falls ja, jetzt sofort verbrauchen Hash + Encrypted nullen, damit
// weder Re-Login noch Klartext-Abruf möglich ist. Customer landet im
// Force-Change-Password-Flow.
const mustChangePassword = customer.portalPasswordMustChange === true;
if (mustChangePassword) {
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: null,
portalPasswordEncrypted: null,
portalLastLogin: new Date(),
},
});
} else {
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
}
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map(
(rep) => rep.customer.id
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
// GEFILTERT auf aktive Vollmacht (isGranted: true). Ohne diesen Filter
// hätte das frische JWT nach Vollmacht-Widerruf weiterhin die alte
// representedCustomerIds-Liste; die UI würde dem Vertreter noch
// anzeigen, dass er vertreten kann, obwohl der Live-Check beim
// Datenzugriff dann 403 wirft. Pentest Runde 10 (2026-05-17), MEDIUM.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
);
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
// Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [
@@ -135,12 +243,13 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
return {
token,
accessToken,
refreshToken,
mustChangePassword,
user: {
id: customer.id,
email: customer.portalEmail,
@@ -149,7 +258,8 @@ export async function customerLogin(email: string, password: string) {
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
mustChangePassword,
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -161,26 +271,142 @@ export async function customerLogin(email: string, password: string) {
};
}
// Refresh-Token verifizieren und neuen Access-Token ausstellen. Wirft bei
// ungültigem/abgelaufenem/invalidiertem Token. Greift auch tokenInvalidatedAt
// vom User/Customer ab → bei Rolle-Ändern oder Logout sind alle Tokens (auch
// das Refresh) sofort tot.
export async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
user: any;
}> {
let decoded: any;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
});
} catch {
throw new Error('Refresh-Token ungültig oder abgelaufen');
}
if (decoded.type !== 'refresh') {
throw new Error('Falscher Token-Typ');
}
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
if (!decoded.isCustomerPortal && decoded.userId) {
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
},
});
if (!user || !user.isActive) throw new Error('Benutzer nicht aktiv');
if (user.tokenInvalidatedAt && issuedAt < user.tokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
}
const permissions = new Set<string>();
for (const ur of user.roles) {
for (const rp of ur.role.permissions) {
permissions.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
const payload: JwtPayload = {
userId: user.id,
email: user.email,
permissions: Array.from(permissions),
customerId: user.customerId ?? undefined,
isCustomerPortal: false,
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
},
};
}
// Customer-Portal
if (decoded.isCustomerPortal && decoded.customerId) {
const customer = await prisma.customer.findUnique({ where: { id: decoded.customerId } });
if (!customer || !customer.portalEmail) throw new Error('Portal-Konto nicht gefunden');
if (customer.portalTokenInvalidatedAt && issuedAt < customer.portalTokenInvalidatedAt.getTime()) {
throw new Error('Refresh-Token wurde invalidiert');
}
const portalUser = await getCustomerPortalUser(customer.id);
if (!portalUser) throw new Error('Portal-Konto nicht gefunden');
const payload: JwtPayload = {
email: customer.portalEmail,
permissions: portalUser.permissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomerIds: portalUser.representedCustomers?.map((c: any) => c.id),
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: portalUser,
};
}
throw new Error('Refresh-Token konnte nicht interpretiert werden');
}
// Kundenportal-Passwort setzen/ändern
export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
const hashedPassword = await bcrypt.hash(password, 10);
const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
const encryptedPassword = encrypt(password);
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
// Manuelles Setzen ist KEIN Einmalpasswort → Flag immer zurücksetzen,
// falls vorher ein OTP gesetzt war.
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword,
portalPasswordMustChange: false,
},
});
console.log('[SetPortalPassword] Passwort gespeichert');
}
// Vom Endkunden selbst gesetztes Initial-Passwort nach OTP-Login.
// Speichert neuen Hash, löscht das verbrauchte Encrypted-Feld (Klartext-
// Speicherung soll bei OFF self-service nicht zurückkommen) und invalidiert
// sofort alle bestehenden Sessions, damit Login mit dem neuen Passwort
// gefordert wird.
export async function changeInitialPortalPassword(customerId: number, newPassword: string) {
const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: null,
portalPasswordMustChange: false,
portalTokenInvalidatedAt: new Date(),
},
});
}
export async function markPortalPasswordForChange(customerId: number) {
await prisma.customer.update({
where: { id: customerId },
data: { portalPasswordMustChange: true },
});
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({
@@ -208,7 +434,7 @@ export async function createUser(data: {
roleIds: number[];
customerId?: number;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);
const user = await prisma.user.create({
data: {
@@ -320,6 +546,13 @@ export async function getCustomerPortalUser(customerId: number) {
'customers:read',
];
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
const grantedCustomerIds = new Set(await getAuthorizedCustomerIds(customer.id));
const grantedRepresentingFor = customer.representingFor.filter((rep) =>
grantedCustomerIds.has(rep.customer.id),
);
return {
id: customer.id,
email: customer.portalEmail,
@@ -329,7 +562,7 @@ export async function getCustomerPortalUser(customerId: number) {
customerId: customer.id,
permissions: customerPermissions,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
representedCustomers: grantedRepresentingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
@@ -339,3 +572,278 @@ export async function getCustomerPortalUser(customerId: number) {
})),
};
}
// ==================== PASSWORT-RESET ====================
const RESET_TOKEN_EXPIRY_HOURS = 2;
function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173';
}
/**
* Portal-Zugangsdaten per E-Mail an den Kunden versenden. Nur durch Admin-
* UI ausgelöst nie automatisch , weil das Klartext-Passwort im Mail-
* Body steht. Login-URL zeigt auf das `/portal/login`-Frontend-Route.
*/
export async function sendPortalCredentialsEmail(params: {
to: string;
customer: { firstName: string | null; lastName: string | null; salutation: string | null; companyName: string | null };
loginEmail: string;
password: string;
}): Promise<void> {
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
throw new Error('Kein System-E-Mail-Konto konfiguriert (Einstellungen → E-Mail-Provider)');
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const loginUrl = `${getPublicUrl()}/portal/login`;
const name = params.customer.companyName?.trim()
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|| 'Kunde';
// HTML-Escape Customer-Namen können theoretisch Sonderzeichen enthalten,
// die wir nicht ungefiltert in die Mail rendern wollen.
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Ihre Zugangsdaten zum Kundenportal</h2>
<p>Hallo ${esc(name)},</p>
<p>anbei Ihre Zugangsdaten zum Kundenportal:</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 6px 12px; color: #6b7280;">Login-URL:</td>
<td style="padding: 6px 12px;"><a href="${loginUrl}">${esc(loginUrl)}</a></td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">E-Mail:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.loginEmail)}</td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280;">Passwort:</td>
<td style="padding: 6px 12px; font-family: monospace;">${esc(params.password)}</td></tr>
</table>
<p style="color: #b91c1c; font-size: 14px; font-weight: 600;">
⚠️ Dieses Passwort ist ein <u>Einmalpasswort</u>.
</p>
<p style="color: #6b7280; font-size: 14px;">
Beim ersten Login werden Sie aufgefordert, ein eigenes Passwort zu vergeben.
Danach ist dieses Passwort hier <strong>nicht mehr gültig</strong> falls Sie den
Vorgang abbrechen, fordern Sie bitte neue Zugangsdaten an oder nutzen die
Passwort-vergessen-Funktion.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Diese Nachricht enthält sensible Zugangsdaten bitte sicher verwahren oder nach
dem Login löschen.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: params.to,
subject: 'Ihre Zugangsdaten zum Kundenportal',
html,
},
{
context: 'portal-credentials',
triggeredBy: 'admin-action',
},
);
}
/**
* Passwort-Reset-Link per Email senden.
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
* (Schutz vor User-Enumeration Caller gibt immer success zurück).
*/
export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise<void> {
const token = generateResetToken();
const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
let recipient: { email: string; firstName: string; lastName: string } | null = null;
if (userType === 'admin') {
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) return;
await prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expiresAt,
},
});
recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName };
} else {
const customer = await prisma.customer.findUnique({ where: { portalEmail: email } });
if (!customer || !customer.portalEnabled) return;
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordResetToken: token,
portalPasswordResetExpiresAt: expiresAt,
},
});
recipient = {
email: customer.portalEmail!,
firstName: customer.firstName,
lastName: customer.lastName,
};
}
if (!recipient) return;
// Reset-Link + Email senden
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
console.warn(
`[passwordReset] Kein System-E-Mail konfiguriert Reset-Link für ${recipient.email}: ${resetUrl}`,
);
return;
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
<p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
<p>
Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden
Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig.
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Neues Passwort vergeben
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach
Ihr Passwort bleibt unverändert.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: recipient.email,
subject: 'Passwort zurücksetzen',
html,
},
{
context: 'password-reset',
triggeredBy: 'self-service',
},
);
}
/**
* Stellt fest, ob ein Reset-Token zu einem Mitarbeiter (admin) oder einem
* Portal-Customer (portal) gehört. Wird vom Controller benötigt, um den
* passenden Komplexitäts-Schwellwert (25 bzw. 12 Zeichen) anzuwenden,
* BEVOR das Passwort tatsächlich gesetzt wird. Pentest Runde 13.
*/
export async function getPasswordResetAudience(token: string): Promise<'admin' | 'portal' | null> {
const user = await prisma.user.findUnique({
where: { passwordResetToken: token },
select: { id: true },
});
if (user) return 'admin';
const customer = await prisma.customer.findUnique({
where: { portalPasswordResetToken: token },
select: { id: true },
});
if (customer) return 'portal';
return null;
}
/**
* Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
* Invalidiert alle bestehenden JWT-Sessions des Users.
*/
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
// Erst beim User suchen
const user = await prisma.user.findUnique({ where: { passwordResetToken: token } });
if (user) {
if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) {
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
}
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
passwordResetToken: null,
passwordResetExpiresAt: null,
// Alle bestehenden Sessions kicken
tokenInvalidatedAt: new Date(),
},
});
return;
}
// Sonst beim Customer (Portal)
const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } });
if (customer) {
if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) {
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
}
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: hash,
// Pentest Runde 6 (MITTEL-01): Beim Self-Service-Reset speichern wir
// KEINEN Klartext mehr. Encrypted-Feld ist nur für Admin-generierte
// Einmalpasswörter sinnvoll (damit Admin sie in der UI sehen + per
// Mail versenden kann); für ein vom Kunden selbst gesetztes Passwort
// ist Klartext-Speicherung ein unnötiges Recover-Risiko bei DB+Key-Leak.
portalPasswordEncrypted: null,
portalPasswordResetToken: null,
portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(),
// OTP-Flow-Flag ist nach selbstgesetztem Passwort definitiv aus
portalPasswordMustChange: false,
},
});
return;
}
throw new Error('Ungültiger oder bereits verwendeter Link.');
}
+201 -7
View File
@@ -138,6 +138,24 @@ function deleteDirectory(dirPath: string): void {
fs.rmdirSync(dirPath);
}
// Wie deleteDirectory, ABER das Ziel-Verzeichnis selbst bleibt stehen
// nur die Inhalte verschwinden. Notwendig für Docker-Bind-Mounts wie
// `/app/uploads`: dort wirft `rmdir` ein EBUSY, weil das Volume vom Host
// gemountet ist und sich nicht aushängen lässt.
function emptyDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) return;
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.lstatSync(itemPath);
if (stats.isDirectory()) {
deleteDirectory(itemPath);
} else {
fs.unlinkSync(itemPath);
}
}
}
/**
* Liste aller verfügbaren Backups
*/
@@ -239,6 +257,17 @@ export async function createBackup(): Promise<BackupResult> {
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
// Neue Tabellen
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'SecurityEvent', query: () => prisma.securityEvent.findMany() },
];
let totalRecords = 0;
@@ -297,6 +326,11 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
console.log('[Restore] Lösche alle bestehenden Daten...');
// Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({});
await prisma.securityEvent.deleteMany({});
// Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({});
await prisma.tvContractDetails.deleteMany({});
@@ -309,12 +343,21 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.meterReading.deleteMany({});
await prisma.contractHistoryEntry.deleteMany({});
// Neue Contract-bezogene Tabellen
await prisma.contractDocument.deleteMany({});
await prisma.contractMeter.deleteMany({});
// E-Mail & Verträge
await prisma.cachedEmail.deleteMany({});
await prisma.contractTaskSubtask.deleteMany({});
await prisma.contractTask.deleteMany({});
await prisma.contract.deleteMany({});
// DSGVO + Vollmachten (abhängig von Customer)
await prisma.representativeAuthorization.deleteMany({});
await prisma.customerConsent.deleteMany({});
await prisma.dataDeletionRequest.deleteMany({});
// Kunden-bezogene Daten
await prisma.stressfreiEmail.deleteMany({});
await prisma.meter.deleteMany({});
@@ -328,7 +371,8 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.user.deleteMany({});
await prisma.customer.deleteMany({});
// Stammdaten
// Stammdaten & Kataloge
await prisma.pdfTemplate.deleteMany({});
await prisma.tariff.deleteMany({});
await prisma.provider.deleteMany({});
await prisma.rolePermission.deleteMany({});
@@ -340,6 +384,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.contractCategory.deleteMany({});
await prisma.emailProviderConfig.deleteMany({});
await prisma.appSetting.deleteMany({});
await prisma.auditRetentionPolicy.deleteMany({});
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
@@ -753,6 +798,127 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
}
},
},
// Neue Tabellen
{
name: 'PdfTemplate',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.pdfTemplate.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractMeter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractMeter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'RepresentativeAuthorization',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.representativeAuthorization.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CustomerConsent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerConsent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'DataDeletionRequest',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.dataDeletionRequest.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditRetentionPolicy',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditRetentionPolicy.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SecurityEvent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.securityEvent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
];
let totalRestored = 0;
@@ -778,10 +944,10 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
let restoredFiles = 0;
const uploadsBackupDir = path.join(backupDir, 'uploads');
if (fs.existsSync(uploadsBackupDir)) {
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
if (fs.existsSync(UPLOADS_DIR)) {
deleteDirectory(UPLOADS_DIR);
}
// Inhalte leeren, das Verzeichnis selbst NICHT löschen
// UPLOADS_DIR ist im Container ein Bind-Mount auf den Host und
// `rmdir` darauf liefert EBUSY (siehe emptyDirectory()).
emptyDirectory(UPLOADS_DIR);
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
}
@@ -873,8 +1039,36 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir);
// ZIP extrahieren
zip.extractAllTo(finalBackupDir, true);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true });
for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName;
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
}
const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
return {
success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
};
}
if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben
fs.writeFileSync(targetPath, entry.getData());
}
}
return { success: true, backupName: finalBackupName };
} catch (error: any) {
+137 -3
View File
@@ -140,6 +140,9 @@ export interface MyBirthdayCheck {
isToday: boolean;
daysAgo: number; // 0 = heute, >0 = x Tage her
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
age: number;
}
@@ -153,6 +156,9 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
birthDate: true,
lastBirthdayGreetingYear: true,
},
@@ -160,13 +166,20 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
if (!customer?.birthDate) return null;
const baseInfo = {
firstName: customer.firstName,
lastName: customer.lastName,
salutation: customer.salutation,
useInformalAddress: customer.useInformalAddress,
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
// Schon dieses Jahr angezeigt?
if (customer.lastBirthdayGreetingYear === thisYear) {
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
}
const birthday = new Date(thisYear, customer.birthDate.getMonth(), customer.birthDate.getDate());
@@ -177,7 +190,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
// Nur wenn heute oder in den letzten 7 Tagen (diff: 07)
if (diff < 0 || diff > 7) {
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
}
const age = calculateAge(customer.birthDate, today);
@@ -186,7 +199,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
show: true,
isToday: diff === 0,
daysAgo: diff,
firstName: customer.firstName,
...baseInfo,
age,
};
}
@@ -201,3 +214,124 @@ export async function acknowledgeBirthdayGreeting(customerId: number): Promise<v
data: { lastBirthdayGreetingYear: thisYear },
});
}
/**
* Setzt den Gruß-Marker zurück, damit das Modal beim nächsten Login wieder erscheint.
* (Für Mitarbeiter nützlich zum Debuggen und als Fallback wenn etwas schief ging.)
*/
export async function resetBirthdayGreeting(customerId: number): Promise<void> {
await prisma.customer.update({
where: { id: customerId },
data: { lastBirthdayGreetingYear: null },
});
}
/**
* Generiert den persönlichen Geburtstagsgruß-Text (Du/Sie-abhängig).
*/
export function buildBirthdayGreetingText(
customer: {
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
},
age: number,
): { subject: string; plain: string; html: string } {
const name = customer.useInformalAddress
? customer.firstName
: [customer.salutation, customer.lastName].filter(Boolean).join(' ') || customer.firstName;
const du = customer.useInformalAddress;
const pronoun = du ? 'dir' : 'Ihnen';
const possessive = du ? 'deinem' : 'Ihrem';
const yourLower = du ? 'dein' : 'Ihr';
const subject = du
? `Alles Gute zum Geburtstag, ${customer.firstName}! 🎉`
: 'Herzlichen Glückwunsch zum Geburtstag 🎉';
// Plain-Text ohne Emojis, damit WhatsApp/Telegram/Signal-URL-Handler nicht stolpern
const plain = [
`Herzlichen Glückwunsch, ${name}!`,
'',
age > 0
? `Alles Gute zu ${possessive} ${age}. Geburtstag!`
: `Alles Gute zu ${possessive} Geburtstag!`,
'',
`Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr.`,
'',
'Herzliche Grüße',
'Hacker-Net Telekommunikation',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 50%, #6366f1 100%); padding: 40px 20px; text-align: center; border-radius: 12px 12px 0 0;">
<div style="font-size: 64px; margin-bottom: 8px;">🎉🎂🎈</div>
</div>
<div style="padding: 32px; background: #ffffff; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<h2 style="color: #1f2937; margin-top: 0;">Herzlichen Glückwunsch, ${name}!</h2>
<p style="color: #4b5563; font-size: 16px; line-height: 1.6;">
${age > 0 ? `Alles Gute zu ${possessive} <strong>${age}. Geburtstag</strong>!` : `Alles Gute zu ${possessive} Geburtstag!`}
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Herzliche Grüße<br>
<strong>Hacker-Net Telekommunikation</strong><br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
</div>
`;
return { subject, plain, html };
}
/**
* Lädt die für den Gruß benötigten Kundendaten inkl. aktuellem Alter heute.
*/
export async function getBirthdayGreetingData(customerId: number): Promise<{
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
age: number;
} | null> {
const c = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
email: true,
phone: true,
mobile: true,
birthDate: true,
},
});
if (!c?.birthDate) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const age = calculateAge(c.birthDate, today);
return {
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
email: c.email,
phone: c.phone,
mobile: c.mobile,
age,
};
}
@@ -0,0 +1,169 @@
/**
* Scheduler für automatische Geburtstagsgrüße.
*
* Läuft täglich um 08:00 Uhr und sendet Grüße an alle Kunden mit:
* - Geburtstag = heute
* - autoBirthdayGreeting = true
* - autoBirthdayChannel ist gesetzt (aktuell nur 'email' automatisiert)
* - lastBirthdayGreetingYear != aktuelles Jahr (verhindert Doppel-Versand)
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import * as birthdayService from './birthday.service.js';
async function runDailyBirthdayGreetings(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
const month = today.getMonth() + 1; // Prisma-Raw-SQL ist 1-indexed
const day = today.getDate();
console.log(
`[BirthdayScheduler] Suche Kunden mit Geburtstag ${day}.${month}., Auto-Versand aktiv …`,
);
// Kunden mit heutigem Geburtstag + Auto-Versand + dieses Jahr noch nicht gesendet
const candidates = await prisma.$queryRaw<
Array<{
id: number;
firstName: string;
lastName: string;
email: string | null;
salutation: string | null;
useInformalAddress: boolean;
birthDate: Date;
autoBirthdayChannel: string | null;
}>
>`
SELECT id, firstName, lastName, email, salutation, useInformalAddress, birthDate, autoBirthdayChannel
FROM Customer
WHERE autoBirthdayGreeting = 1
AND birthDate IS NOT NULL
AND MONTH(birthDate) = ${month}
AND DAY(birthDate) = ${day}
AND (lastBirthdayGreetingYear IS NULL OR lastBirthdayGreetingYear != ${thisYear})
`;
if (candidates.length === 0) {
console.log('[BirthdayScheduler] Keine passenden Kunden heute.');
return;
}
console.log(`[BirthdayScheduler] ${candidates.length} Kunde(n) gefunden sende Grüße.`);
// System-E-Mail-Credentials einmal laden
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
console.error(
'[BirthdayScheduler] Keine System-E-Mail konfiguriert kann keine Grüße versenden.',
);
return;
}
const smtpCreds: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
let sent = 0;
let skipped = 0;
for (const c of candidates) {
const channel = c.autoBirthdayChannel || 'email';
// Aktuell nur Email automatisch Messenger brauchen Browser-Klick
if (channel !== 'email') {
console.log(
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): Kanal "${channel}" nicht automatisierbar, übersprungen.`,
);
skipped++;
continue;
}
if (!c.email) {
console.log(
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): keine E-Mail hinterlegt, übersprungen.`,
);
skipped++;
continue;
}
const age = thisYear - new Date(c.birthDate).getFullYear();
const { subject, html } = birthdayService.buildBirthdayGreetingText(
{
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
},
age,
);
try {
const result = await sendEmail(
smtpCreds,
systemEmail.emailAddress,
{ to: c.email, subject, html },
{ context: 'birthday-greeting-auto', customerId: c.id, triggeredBy: 'cron' },
);
if (result.success) {
// Marker setzen damit nächstes Jahr wieder läuft, dieses Jahr aber nicht nochmal
await prisma.customer.update({
where: { id: c.id },
data: { lastBirthdayGreetingYear: thisYear },
});
sent++;
console.log(
`[BirthdayScheduler] ✓ Kunde #${c.id} (${c.firstName} ${c.lastName}): Gruß gesendet.`,
);
} else {
console.error(
`[BirthdayScheduler] ✗ Kunde #${c.id}: Sendfehler: ${result.error}`,
);
skipped++;
}
} catch (err) {
console.error(`[BirthdayScheduler] ✗ Kunde #${c.id}: Exception:`, err);
skipped++;
}
}
console.log(
`[BirthdayScheduler] Fertig: ${sent} versendet, ${skipped} übersprungen von ${candidates.length} Kandidaten.`,
);
}
/**
* Scheduler starten. Läuft täglich um 08:00 in lokaler Server-Zeit.
* Zusätzlich: ein Test-Lauf 30 Sekunden nach Server-Start, aber nur wenn heute schon jemand Geburtstag hat
* (sonst passiert eh nichts). So können wir bei Ausfall am Tag X direkt beim nächsten Boot nachholen.
*/
export function startBirthdayScheduler(): void {
// Täglich um 08:00
cron.schedule('0 8 * * *', () => {
runDailyBirthdayGreetings().catch((err) =>
console.error('[BirthdayScheduler] Daily run failed:', err),
);
});
// Einmal 30 Sekunden nach Start (Catch-up bei Ausfall)
setTimeout(() => {
runDailyBirthdayGreetings().catch((err) =>
console.error('[BirthdayScheduler] Catch-up run failed:', err),
);
}, 30_000);
console.log('[BirthdayScheduler] Gestartet täglich um 08:00 + Catch-up nach 30s');
}
/**
* Für manuelles Triggern (z.B. aus Debug-Endpoint).
*/
export { runDailyBirthdayGreetings };
@@ -49,6 +49,18 @@ export interface EmailListOptions {
limit?: number;
offset?: number;
includeBody?: boolean;
// Suche / Filter (alle AND-verknüpft)
search?: string; // Volltextsuche über subject + from + body
fromFilter?: string; // Absender enthält
toFilter?: string; // Empfänger enthält
subjectFilter?: string; // Subject enthält
bodyFilter?: string; // Body enthält (text/html)
attachmentNameFilter?: string; // Anhang-Dateiname enthält
hasAttachments?: boolean; // Nur mit/ohne Anhang
isRead?: boolean; // Gelesen-Status
isStarred?: boolean; // Markiert-Status
receivedFrom?: Date; // Empfangen ab
receivedTo?: Date; // Empfangen bis
}
// ==================== SYNC FUNCTIONS ====================
@@ -273,6 +285,59 @@ export async function getCachedEmails(
where.folder = EmailFolder.INBOX;
}
// ===== Such-/Filter-Parameter =====
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
if (options.search && options.search.trim()) {
const q = options.search.trim();
where.OR = [
{ subject: { contains: q } },
{ fromAddress: { contains: q } },
{ fromName: { contains: q } },
{ textBody: { contains: q } },
];
}
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
if (options.fromFilter?.trim()) {
const q = options.fromFilter.trim();
// Treffer in fromAddress ODER fromName für den Nutzer ist „Von" beides
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
];
}
if (options.toFilter?.trim()) {
where.toAddresses = { contains: options.toFilter.trim() };
}
if (options.subjectFilter?.trim()) {
where.subject = { contains: options.subjectFilter.trim() };
}
if (options.bodyFilter?.trim()) {
const q = options.bodyFilter.trim();
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
];
}
if (options.attachmentNameFilter?.trim()) {
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
}
if (typeof options.hasAttachments === 'boolean') {
where.hasAttachments = options.hasAttachments;
}
if (typeof options.isRead === 'boolean') {
where.isRead = options.isRead;
}
if (typeof options.isStarred === 'boolean') {
where.isStarred = options.isStarred;
}
if (options.receivedFrom || options.receivedTo) {
where.receivedAt = {};
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
}
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
const select: Prisma.CachedEmailSelect = {
id: true,
@@ -185,3 +185,105 @@ export async function generateConsentPdf(customerId: number): Promise<Buffer> {
doc.end();
});
}
/**
* Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren.
* Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel +
* ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift +
* Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom
* Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz"
* wieder hochgeladen.
*/
export async function generateSignablePrivacyPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true, lastName: true, customerNumber: true, companyName: true,
salutation: true,
},
});
const printedName = customer
? (customer.companyName?.trim()
? customer.companyName.trim()
: `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim())
: '';
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(0.5);
// Kundenkopf
if (printedName) {
doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' });
}
if (customer?.customerNumber) {
doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' });
}
doc.moveDown(0.5);
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, { align: 'left', lineGap: 4 });
// Genug Platz vor dem Unterschriftenblock wenn nicht mehr genug
// Platz auf der Seite, neue Seite anfangen.
if (doc.y > doc.page.height - doc.page.margins.bottom - 220) {
doc.addPage();
} else {
doc.moveDown(2);
}
// Einwilligungsklausel
doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false });
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica').text(
'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' +
'Datenschutzerklärung gelesen und verstanden habe und mit der ' +
'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' +
'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' +
'jederzeit für die Zukunft widerrufen werden.',
{ align: 'left', lineGap: 3 },
);
doc.moveDown(1.5);
// Unterschriftenblock: links Ort/Datum, rechts Unterschrift
const startY = doc.y;
const leftX = doc.page.margins.left;
const rightX = doc.page.width / 2 + 10;
const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10;
// Linien
const lineY = startY + 35;
doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke();
doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke();
// Labels unter den Linien
doc.fontSize(9).font('Helvetica');
doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' });
doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' });
// Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde)
doc.moveDown(3);
const nameY = doc.y;
doc.fontSize(11).font('Helvetica');
if (printedName) {
doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' });
}
doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke();
doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' });
doc.end();
});
}
+18
View File
@@ -3,6 +3,24 @@ import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
// 'public-link', CRM-Backend-Override 'crm-backend'. Alles andere
// (z.B. "ADMIN_OVERRIDE", "<script>") wird abgelehnt Pentest 2026-05-20.
export const ALLOWED_CONSENT_SOURCES: ReadonlySet<string> = new Set([
'portal',
'public-link',
'telefon',
'papier',
'email',
'crm-backend',
]);
export function sanitizeConsentSource(value: unknown, fallback: string): string {
const v = typeof value === 'string' ? value : '';
return ALLOWED_CONSENT_SOURCES.has(v) ? v : fallback;
}
export interface UpdateConsentData {
status: ConsentStatus;
source?: string;

Some files were not shown because too many files have changed in this diff Show More