/** * 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; 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/.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((p) => ({ name: p.name, portalUrl: p.portalUrl, usernameFieldName: p.usernameFieldName, passwordFieldName: p.passwordFieldName, 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((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 { 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((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(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/`), niemals auf einen aus dem ZIP konstruierten * Pfad im Filesystem. */ export async function importFactoryDefaults( zipBuffer: Buffer, ): Promise { 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(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, isActive: p.isActive ?? true, }, create: { name: p.name, portalUrl: p.portalUrl ?? null, usernameFieldName: p.usernameFieldName ?? null, passwordFieldName: p.passwordFieldName ?? 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( 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(zip, 'app-settings/app-settings.json'); const allowedKeys = new Set(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; }