2c7a87ccd3
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>
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
/**
|
||
* 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/<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,
|
||
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,
|
||
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<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;
|
||
}
|