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>
This commit is contained in:
@@ -1,15 +1,32 @@
|
||||
/**
|
||||
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen –
|
||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails –
|
||||
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||
* Vertragskategorien und PDF-Auftragsvorlagen.
|
||||
* 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;
|
||||
@@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest {
|
||||
contractDurations: number;
|
||||
contractCategories: number;
|
||||
pdfTemplates: number;
|
||||
appSettings: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +67,7 @@ export interface PdfTemplateExport {
|
||||
* Sammelt alle Katalog-Daten aus der DB.
|
||||
*/
|
||||
export async function collectFactoryDefaults() {
|
||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
|
||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
|
||||
await Promise.all([
|
||||
prisma.provider.findMany({
|
||||
include: { tariffs: { select: { name: true, isActive: true } } },
|
||||
@@ -59,6 +77,11 @@ export async function collectFactoryDefaults() {
|
||||
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 {
|
||||
@@ -108,6 +131,7 @@ export async function collectFactoryDefaults() {
|
||||
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
||||
};
|
||||
}),
|
||||
appSettings: appSettings as AppSettingExport[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +156,7 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
||||
contractDurations: data.contractDurations.length,
|
||||
contractCategories: data.contractCategories.length,
|
||||
pdfTemplates: data.pdfTemplates.length,
|
||||
appSettings: data.appSettings.length,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -160,6 +185,9 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
||||
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');
|
||||
@@ -192,3 +220,244 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user