Files
opencrm/backend/src/services/factoryDefaults.service.ts
T
duffyduck 8b10316683 Anbieter: Kontakt + Kündigung als Stammdaten
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.

Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:10:59 +02:00

492 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import AdmZip from 'adm-zip';
import prisma from '../lib/prisma.js';
// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören.
// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte
// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings.
export const FACTORY_DEFAULT_APP_SETTING_KEYS = [
'privacyPolicyHtml',
'authorizationTemplateHtml',
'imprintHtml',
'websitePrivacyPolicyHtml',
] as const;
export interface AppSettingExport {
key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number];
value: string;
}
export interface FactoryDefaultsManifest {
version: 1;
exportedAt: string;
counts: {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
appSettings: number;
};
}
export interface ProviderExport {
name: string;
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 }[];
}
export interface PdfTemplateExport {
name: string;
description: string | null;
providerName: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix: string | null;
maxPhoneFields: number | null;
isActive: boolean;
// Datei wird separat im ZIP abgelegt unter pdf-templates/<name>.pdf
pdfFilename: string;
}
/**
* Sammelt alle Katalog-Daten aus der DB.
*/
export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
await Promise.all([
prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } },
orderBy: { name: 'asc' },
}),
prisma.cancellationPeriod.findMany({ orderBy: { code: 'asc' } }),
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
prisma.appSetting.findMany({
where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } },
select: { key: true, value: true },
orderBy: { key: 'asc' },
}),
]);
return {
providers: providers.map<ProviderExport>((p) => ({
name: p.name,
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 })),
})),
cancellationPeriods: cancellationPeriods.map((c) => ({
code: c.code,
description: c.description,
isActive: c.isActive,
})),
contractDurations: contractDurations.map((d) => ({
code: d.code,
description: d.description,
isActive: d.isActive,
})),
contractCategories: contractCategories.map((c) => ({
code: c.code,
name: c.name,
icon: c.icon,
color: c.color,
sortOrder: c.sortOrder,
isActive: c.isActive,
})),
pdfTemplates: pdfTemplates.map<PdfTemplateExport>((t) => {
// fieldMapping ist im DB als String abgelegt zu Objekt parsen
let fieldMapping: any = {};
try {
fieldMapping = JSON.parse(t.fieldMapping);
} catch {
fieldMapping = {};
}
return {
name: t.name,
description: t.description,
providerName: t.providerName,
originalName: t.originalName,
fieldMapping,
phoneFieldPrefix: t.phoneFieldPrefix,
maxPhoneFields: t.maxPhoneFields,
isActive: t.isActive,
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
};
}),
appSettings: appSettings as AppSettingExport[],
};
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_').replace(/_+/g, '_');
}
/**
* Erstellt ein ZIP-Archiv mit allen Factory-Defaults.
* Die PDF-Dateien werden aus dem uploads-Verzeichnis gelesen.
*/
export async function exportFactoryDefaults(): Promise<Buffer> {
const data = await collectFactoryDefaults();
const manifest: FactoryDefaultsManifest = {
version: 1,
exportedAt: new Date().toISOString(),
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,
},
};
return new Promise<Buffer>((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', reject);
// JSON-Dateien vorbereiten
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
archive.append(JSON.stringify(data.providers, null, 2), {
name: 'providers/providers.json',
});
archive.append(JSON.stringify(data.cancellationPeriods, null, 2), {
name: 'contract-meta/cancellation-periods.json',
});
archive.append(JSON.stringify(data.contractDurations, null, 2), {
name: 'contract-meta/contract-durations.json',
});
archive.append(JSON.stringify(data.contractCategories, null, 2), {
name: 'contract-meta/contract-categories.json',
});
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
name: 'pdf-templates/pdf-templates.json',
});
archive.append(JSON.stringify(data.appSettings, null, 2), {
name: 'app-settings/app-settings.json',
});
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
const uploadsRoot = path.join(process.cwd(), 'uploads');
(async () => {
try {
const templateRows = await prisma.pdfTemplate.findMany({
select: { name: true, templatePath: true },
});
const pathByName = new Map(templateRows.map((r) => [r.name, r.templatePath]));
for (const t of data.pdfTemplates) {
const storedPath = pathByName.get(t.name);
if (!storedPath) continue;
const relPath = storedPath.startsWith('/uploads/')
? storedPath.substring('/uploads/'.length)
: storedPath;
const absPath = path.join(uploadsRoot, relPath);
if (fs.existsSync(absPath)) {
archive.file(absPath, { name: `pdf-templates/${t.pdfFilename}` });
} else {
console.warn(`[factoryDefaults] PDF-Datei nicht gefunden: ${absPath}`);
}
}
archive.finalize();
} catch (err) {
reject(err);
}
})();
});
}
// ============================================================
// IMPORT
// ============================================================
export interface FactoryDefaultsImportResult {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
pdfTemplatesSkipped: number;
appSettings: number;
warnings: string[];
}
function parseJsonEntry<T>(zip: AdmZip, name: string): T[] {
const entry = zip.getEntry(name);
if (!entry) return [];
try {
const parsed = JSON.parse(entry.getData().toString('utf-8'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Wendet ein Factory-Defaults-ZIP idempotent auf die DB an.
* - upsert über unique-Keys: nichts wird gelöscht
* - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix
* - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS)
*
* Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu
* (`pdf-templates/<basename>`), niemals auf einen aus dem ZIP konstruierten
* Pfad im Filesystem.
*/
export async function importFactoryDefaults(
zipBuffer: Buffer,
): Promise<FactoryDefaultsImportResult> {
const zip = new AdmZip(zipBuffer);
const result: FactoryDefaultsImportResult = {
providers: 0,
tariffs: 0,
cancellationPeriods: 0,
contractDurations: 0,
contractCategories: 0,
pdfTemplates: 0,
pdfTemplatesSkipped: 0,
appSettings: 0,
warnings: [],
};
// --- Providers + Tariffs
const providers = parseJsonEntry<ProviderExport>(zip, 'providers/providers.json');
for (const p of providers) {
if (!p.name) continue;
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
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: {
name: p.name,
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,
},
});
result.providers++;
for (const t of p.tariffs ?? []) {
if (!t.name) continue;
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 },
});
result.tariffs++;
}
}
// --- Contract-Meta
const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
zip,
'contract-meta/cancellation-periods.json',
);
for (const c of cancellationPeriods) {
if (!c.code || !c.description) continue;
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 },
});
result.cancellationPeriods++;
}
const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
zip,
'contract-meta/contract-durations.json',
);
for (const d of contractDurations) {
if (!d.code || !d.description) continue;
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 },
});
result.contractDurations++;
}
const contractCategories = parseJsonEntry<{
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}>(zip, 'contract-meta/contract-categories.json');
for (const c of contractCategories) {
if (!c.code || !c.name) continue;
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,
},
});
result.contractCategories++;
}
// --- PDF-Vorlagen (JSON + binär aus dem ZIP)
const pdfTemplates = parseJsonEntry<PdfTemplateExport>(
zip,
'pdf-templates/pdf-templates.json',
);
if (pdfTemplates.length > 0) {
const uploadsRoot = path.join(process.cwd(), 'uploads');
const pdfDestDir = path.join(uploadsRoot, 'pdf-templates');
if (!fs.existsSync(pdfDestDir)) {
fs.mkdirSync(pdfDestDir, { recursive: true });
}
for (const t of pdfTemplates) {
if (!t.name || !t.pdfFilename) continue;
// Anti-Zip-Slip: nur basename verwenden, kein Pfad
const basename = path.basename(t.pdfFilename);
const entry = zip.getEntry(`pdf-templates/${basename}`);
if (!entry) {
result.pdfTemplatesSkipped++;
result.warnings.push(`PDF fehlt im ZIP: ${basename} Vorlage "${t.name}" übersprungen`);
continue;
}
const ext = path.extname(t.originalName || basename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-');
const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`;
const destPdf = path.join(pdfDestDir, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.writeFileSync(destPdf, entry.getData());
// Bei existierender Vorlage die alte Datei aufräumen
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(uploadsRoot, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {});
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,
},
});
result.pdfTemplates++;
}
}
// --- AppSettings (HTML-Templates, Whitelist)
const appSettings = parseJsonEntry<AppSettingExport>(zip, 'app-settings/app-settings.json');
const allowedKeys = new Set<string>(FACTORY_DEFAULT_APP_SETTING_KEYS);
for (const s of appSettings) {
if (!s.key || typeof s.value !== 'string') continue;
if (!allowedKeys.has(s.key)) {
result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist ignoriert`);
continue;
}
await prisma.appSetting.upsert({
where: { key: s.key },
update: { value: s.value },
create: { key: s.key, value: s.value },
});
result.appSettings++;
}
return result;
}