diff --git a/backend/prisma/migrations/20260621100000_provider_contact_and_cancellation/migration.sql b/backend/prisma/migrations/20260621100000_provider_contact_and_cancellation/migration.sql new file mode 100644 index 00000000..584565c3 --- /dev/null +++ b/backend/prisma/migrations/20260621100000_provider_contact_and_cancellation/migration.sql @@ -0,0 +1,13 @@ +-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz. +-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter +-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt. +-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191). + +ALTER TABLE `Provider` + ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL, + ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL, + ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL, + ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL, + ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL, + ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL, + ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bcaeb21e..364e82ae 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -576,6 +576,15 @@ model Provider { portalUrl String? // Kundenkontourl (Login-Seite) usernameFieldName String? // Benutzernamefeld (z.B. "email", "username") passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd") + // Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen) + contactEmail String? // Allgemeine Kontakt-Emailadresse + contactPhone String? // Kontakt-Telefonnummer + contactFax String? // Kontakt-Faxnummer + contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig) + // Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt) + cancellationEmail String? // Kündigungs-Emailadresse + cancellationFax String? // Kündigungs-Faxnummer + cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig) isActive Boolean @default(true) tariffs Tariff[] contracts Contract[] diff --git a/backend/src/services/factoryDefaults.service.ts b/backend/src/services/factoryDefaults.service.ts index ba8a8eb9..1ad05a18 100644 --- a/backend/src/services/factoryDefaults.service.ts +++ b/backend/src/services/factoryDefaults.service.ts @@ -46,6 +46,13 @@ export interface ProviderExport { portalUrl: string | null; usernameFieldName: string | null; passwordFieldName: string | null; + contactEmail: string | null; + contactPhone: string | null; + contactFax: string | null; + contactAddress: string | null; + cancellationEmail: string | null; + cancellationFax: string | null; + cancellationAddress: string | null; isActive: boolean; tariffs: { name: string; isActive: boolean }[]; } @@ -90,6 +97,13 @@ export async function collectFactoryDefaults() { portalUrl: p.portalUrl, usernameFieldName: p.usernameFieldName, passwordFieldName: p.passwordFieldName, + contactEmail: p.contactEmail, + contactPhone: p.contactPhone, + contactFax: p.contactFax, + contactAddress: p.contactAddress, + cancellationEmail: p.cancellationEmail, + cancellationFax: p.cancellationFax, + cancellationAddress: p.cancellationAddress, isActive: p.isActive, tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })), })), @@ -284,6 +298,13 @@ export async function importFactoryDefaults( portalUrl: p.portalUrl ?? null, usernameFieldName: p.usernameFieldName ?? null, passwordFieldName: p.passwordFieldName ?? null, + contactEmail: p.contactEmail ?? null, + contactPhone: p.contactPhone ?? null, + contactFax: p.contactFax ?? null, + contactAddress: p.contactAddress ?? null, + cancellationEmail: p.cancellationEmail ?? null, + cancellationFax: p.cancellationFax ?? null, + cancellationAddress: p.cancellationAddress ?? null, isActive: p.isActive ?? true, }, create: { @@ -291,6 +312,13 @@ export async function importFactoryDefaults( portalUrl: p.portalUrl ?? null, usernameFieldName: p.usernameFieldName ?? null, passwordFieldName: p.passwordFieldName ?? null, + contactEmail: p.contactEmail ?? null, + contactPhone: p.contactPhone ?? null, + contactFax: p.contactFax ?? null, + contactAddress: p.contactAddress ?? null, + cancellationEmail: p.cancellationEmail ?? null, + cancellationFax: p.cancellationFax ?? null, + cancellationAddress: p.cancellationAddress ?? null, isActive: p.isActive ?? true, }, }); diff --git a/backend/src/services/provider.service.ts b/backend/src/services/provider.service.ts index 0345c731..f06e2acc 100644 --- a/backend/src/services/provider.service.ts +++ b/backend/src/services/provider.service.ts @@ -1,5 +1,5 @@ import prisma from '../lib/prisma.js'; -import { stripHtml } from '../utils/sanitize.js'; +import { stripHtml, isValidEmail, sanitizePhoneField, sanitizeNotes } from '../utils/sanitize.js'; import { validateHttpUrl } from '../utils/url.js'; // Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl. @@ -51,48 +51,83 @@ export async function getProviderById(id: number) { // Aktuell escapt React das Textnode, also kein direkter XSS – aber neue // Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären // sofort betroffen. Defense-in-depth: schon beim Schreiben strippen. -function stripProviderStrings(data: T): T { +// +// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch +// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates), +// contactPhone/contactFax/cancellationFax durch sanitizePhoneField +// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit +// 500-Cap (mehrzeilig, normalisierte Newlines). +function stripProviderStrings(data: T): T { const out: any = { ...data }; - if (typeof out.name === 'string') out.name = stripHtml(out.name); - if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName); - if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName); + for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) { + if (typeof out[k] === 'string') out[k] = stripHtml(out[k]); + } + for (const k of ['contactEmail', 'cancellationEmail'] as const) { + if (out[k] === '' || out[k] === null) { out[k] = null; continue; } + if (out[k] === undefined) continue; + const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k]; + const value = typeof stripped === 'string' ? stripped.trim() : stripped; + if (value === '' ) { out[k] = null; continue; } + if (!isValidEmail(value)) { + throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`); + } + out[k] = value; + } + const phoneLabels: Record = { + contactPhone: 'Kontakt-Telefonnummer', + contactFax: 'Kontakt-Faxnummer', + cancellationFax: 'Kündigungs-Faxnummer', + }; + for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) { + if (out[k] === undefined) continue; + if (out[k] === '' || out[k] === null) { out[k] = null; continue; } + const v = sanitizePhoneField(out[k], phoneLabels[k]); + out[k] = v === undefined ? null : v; + } + for (const k of ['contactAddress', 'cancellationAddress'] as const) { + if (out[k] === undefined) continue; + if (out[k] === '' || out[k] === null) { out[k] = null; continue; } + out[k] = sanitizeNotes(out[k], 500); + } return out; } -export async function createProvider(data: { - name: string; +interface ProviderWritable { + name?: string; portalUrl?: string; usernameFieldName?: string; passwordFieldName?: string; -}) { + contactEmail?: string | null; + contactPhone?: string | null; + contactFax?: string | null; + contactAddress?: string | null; + cancellationEmail?: string | null; + cancellationFax?: string | null; + cancellationAddress?: string | null; + isActive?: boolean; +} + +export async function createProvider(data: ProviderWritable & { name: string }) { const clean = stripProviderStrings(data); const portalUrl = assertValidPortalUrl(clean.portalUrl); return prisma.provider.create({ data: { ...clean, + name: clean.name, portalUrl, isActive: true, }, }); } -export async function updateProvider( - id: number, - data: { - name?: string; - portalUrl?: string; - usernameFieldName?: string; - passwordFieldName?: string; - isActive?: boolean; - } -) { +export async function updateProvider(id: number, data: ProviderWritable) { // portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert). // Leerstring = "auf null setzen" - hier setzen wir explizit auf null, // damit Prisma nicht den alten Wert hält. - const updateData: typeof data = stripProviderStrings(data); + const updateData: any = stripProviderStrings(data); if (data.portalUrl !== undefined) { const validated = assertValidPortalUrl(data.portalUrl); - (updateData as { portalUrl: string | null }).portalUrl = validated ?? null; + updateData.portalUrl = validated ?? null; } return prisma.provider.update({ where: { id }, diff --git a/docs/todo.md b/docs/todo.md index 35a226d5..85be3c40 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten** + - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`, + `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`, + `cancellationFax`, `cancellationAddress`. Postadressen als `TEXT` + (mehrzeilig), Rest `VARCHAR(191)`. Migration + `20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`. + - Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt & + Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei + Untergruppen (Kontakt / Kündigung) mit kleinen Headern. + Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen + als `