Factory-Defaults: Export + Import von Stammdaten-Katalogen

Ein neues System um Stammdaten-Kataloge zwischen Installationen zu teilen –
explizit ohne Kundendaten, Verträge oder Einstellungen.

**Was wird exportiert:**
- Anbieter + zugehörige Tarife
- Kündigungsfristen
- Vertragslaufzeiten
- Vertragskategorien
- PDF-Auftragsvorlagen (JSON + PDF-Dateien + Feldzuordnungen)

**Was NICHT:**
- Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen
  → dafür gibt es den Datenbank-Backup

**Neue Einstellungsseite /settings/factory-defaults:**
- Zeigt Anzahl pro Kategorie (Anbieter, Tarife, Fristen, …)
- "Exportieren"-Button lädt ZIP herunter (manifest.json + JSONs + PDFs)
- Import-Anleitung inline

**Import-Script:**
- `npm run seed:defaults` (tsx scripts/seed-factory-defaults.ts)
- Liest alle JSON-Dateien aus backend/factory-defaults/*/*.json
- Merged mehrere Dateien automatisch pro Kategorie (unique-key gewinnt zuletzt)
- Upsertet idempotent → kann mehrfach ausgeführt werden
- Kopiert PDF-Vorlagen aus factory-defaults/pdf-templates/ nach uploads/pdf-templates/
- Alte PDF-Dateien werden beim Re-Import entsorgt

Backend:
- services/factoryDefaults.service.ts: collectFactoryDefaults() + exportFactoryDefaults()
- controllers/factoryDefaults.controller.ts: preview + export
- routes/factoryDefaults.routes.ts: GET /api/factory-defaults/preview + /export
- scripts/seed-factory-defaults.ts: CLI-Import-Script
- .gitignore: factory-defaults/* außer .gitkeep und README.md

Frontend:
- pages/settings/FactoryDefaults.tsx: Übersicht + Export-Button
- Settings-Karte „Factory-Defaults" im System-Abschnitt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:10:12 +02:00
parent 9d5412cef0
commit ad49b92ee9
13 changed files with 913 additions and 40 deletions
+5
View File
@@ -17,6 +17,11 @@ prisma/backups/*
uploads/*
!uploads/.gitkeep
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
factory-defaults/*
!factory-defaults/.gitkeep
!factory-defaults/README.md
# Logs
*.log
npm-debug.log*
View File
+45
View File
@@ -0,0 +1,45 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
## Was ist drin?
- `providers.json` Anbieter inkl. zugehöriger Tarife
- `cancellation-periods.json` Kündigungsfristen (z.B. "14 Tage", "1 Monat")
- `contract-durations.json` Vertragslaufzeiten (z.B. "12 Monate", "24 Monate")
- `contract-categories.json` Vertragskategorien (Strom, Gas, DSL …)
- `pdf-templates.json` + `pdf-templates/*.pdf` PDF-Auftragsvorlagen mit Feldzuordnungen
**NICHT enthalten sind Kundendaten, Verträge, Dokumente, E-Mails oder SMTP-Einstellungen.**
Dafür gibt es den separaten Datenbank-Backup-Export.
## Wie nutze ich das?
### Export (aus bestehender Installation)
In den CRM-Einstellungen → Factory-Defaults → „Exportieren" → ZIP herunterladen
und den Inhalt in diesen Ordner entpacken.
### Import
```bash
npm run seed:defaults
```
Das Script liest alle Dateien aus diesem Ordner, merged mehrere JSONs automatisch
per unique-name und spielt sie per Prisma `upsert` ein. Kann mehrfach ausgeführt
werden (idempotent).
## Mehrere Export-Dateien mergen
Wenn du mehrere ZIPs entpackst (z.B. Provider-Pakete von verschiedenen Quellen),
kannst du die JSON-Dateien frei umbenennen das Script liest alle `*.json` im
jeweiligen Unterordner und merged den Inhalt zusammen.
Beispiel:
```
providers/
verivox.json
check24.json
eigene.json
```
Alle drei werden eingespielt, gleiche Anbieter werden über den `name` gemerged.
+2 -1
View File
@@ -15,7 +15,8 @@
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
"db:restore": "tsx prisma/restore-data.ts"
"db:restore": "tsx prisma/restore-data.ts",
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
+325
View File
@@ -0,0 +1,325 @@
/**
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
*
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
* - Merged mehrere Dateien pro Kategorie automatisch
* - Nutzt Prisma upsert → idempotent, kann mehrfach aufgerufen werden
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
*
* Aufruf:
* npm run seed:defaults
*/
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const ROOT = path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
interface ProviderDef {
name: string;
portalUrl?: string | null;
usernameFieldName?: string | null;
passwordFieldName?: string | null;
isActive?: boolean;
tariffs?: { name: string; isActive?: boolean }[];
}
interface CancellationPeriodDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractDurationDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractCategoryDef {
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}
interface PdfTemplateDef {
name: string;
description?: string | null;
providerName?: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix?: string | null;
maxPhoneFields?: number | null;
isActive?: boolean;
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
}
/**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/
function readJsonArrays<T>(dir: string): T[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const result: T[] = [];
for (const f of files) {
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
result.push(...data);
}
} catch (e) {
console.warn(`⚠ Konnte ${f} nicht parsen überspringe.`);
}
}
return result;
}
/**
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
*/
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return Array.from(map.values());
}
async function seedProviders() {
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
if (items.length === 0) {
console.log(' providers/ keine Einträge gefunden');
return;
}
let providerCount = 0;
let tariffCount = 0;
for (const p of items) {
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,
},
});
providerCount++;
if (p.tariffs && p.tariffs.length > 0) {
for (const t of p.tariffs) {
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,
},
});
tariffCount++;
}
}
}
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
}
async function seedCancellationPeriods() {
const items = dedupe(
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' cancellation-periods keine Einträge');
return;
}
for (const c of relevant) {
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 },
});
}
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
}
async function seedContractDurations() {
const items = dedupe(
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' contract-durations keine Einträge');
return;
}
for (const d of relevant) {
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 },
});
}
console.log(` ✓ Laufzeiten: ${relevant.length}`);
}
async function seedContractCategories() {
const items = dedupe(
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
(i) => i.code,
);
if (items.length === 0) {
console.log(' contract-categories keine Einträge');
return;
}
for (const c of items) {
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,
},
});
}
console.log(` ✓ Vertragskategorien: ${items.length}`);
}
async function seedPdfTemplates() {
const items = dedupe(
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
(t) => t.name,
);
if (items.length === 0) {
console.log(' pdf-templates keine Einträge');
return;
}
// Upload-Verzeichnis sicherstellen
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
}
let count = 0;
let skipped = 0;
for (const t of items) {
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
if (!fs.existsSync(srcPdf)) {
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} Template "${t.name}" übersprungen`);
skipped++;
continue;
}
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.copyFileSync(srcPdf, destPdf);
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
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(UPLOADS_ROOT, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
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,
},
});
count++;
}
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
if (!fs.existsSync(ROOT)) {
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
process.exit(1);
}
await seedProviders();
await seedCancellationPeriods();
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
main()
.catch((e) => {
console.error('\n❌ Fehler beim Einspielen:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,64 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Factory-Defaults als ZIP exportieren (Download).
*/
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
try {
const buffer = await factoryDefaultsService.exportFactoryDefaults();
const dateStr = new Date().toISOString().split('T')[0];
const filename = `factory-defaults-${dateStr}.zip`;
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'FactoryDefaults',
resourceId: dateStr,
resourceLabel: 'Factory-Defaults exportiert',
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
}
}
/**
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
*/
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
try {
const data = await factoryDefaultsService.collectFactoryDefaults();
res.json({
success: true,
data: {
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,
},
},
});
} catch (error) {
console.error('Fehler beim Preview:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
+2
View File
@@ -32,6 +32,7 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
@@ -83,6 +84,7 @@ app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
// Health check
app.get('/api/health', (req, res) => {
@@ -0,0 +1,23 @@
import { Router } from 'express';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Preview (was wäre im Export drin?)
router.get(
'/preview',
authenticate,
requirePermission('settings:read'),
factoryDefaultsController.previewFactoryDefaults,
);
// Export als ZIP-Download
router.get(
'/export',
authenticate,
requirePermission('settings:update'),
factoryDefaultsController.exportFactoryDefaults,
);
export default router;
@@ -0,0 +1,194 @@
/**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien und PDF-Auftragsvorlagen.
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import prisma from '../lib/prisma.js';
export interface FactoryDefaultsManifest {
version: 1;
exportedAt: string;
counts: {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: 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] =
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' } }),
]);
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'),
};
}),
};
}
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,
},
};
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',
});
// 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);
}
})();
});
}
+8 -38
View File
@@ -10,48 +10,18 @@
### Security System testen
### Factory-Defaults: Export + Import von Lieferanten & Formularvorlagen
**Ziel:** Einmal gepflegte Stammdaten (Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
PDF-Auftragsvorlagen) sollen sich exportieren und in andere Installationen oder
als Factory-Default beim Initialisieren wieder einspielen lassen.
**⚠️ Wichtig Abgrenzung:**
- **KEINE Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen o.ä.**
- NUR reine Stammdaten-Kataloge ohne Bezug zu Kunden/Firma
- Für komplette Backups (inkl. Kundendaten, Dokumente, Einstellungen) gibt es bereits
den separaten Backup-Export
**Konzept:**
- Ordner: `backend/factory-defaults/` (gitignoriert für echte Firmen-Exports,
aber mit `.gitkeep` und optional einem öffentlich teilbaren `seeds/`-Unterordner)
Struktur:
```
backend/factory-defaults/
providers/ → providers.json (Anbieter + Tarife)
contract-meta/ → cancellationPeriods.json, contractDurations.json, contractCategories.json
pdf-templates/ → {templateName}.json + dazugehörige PDF-Datei
```
- **Export:** Button in Einstellungen → "Factory-Defaults exportieren"
- Erstellt ZIP mit allen JSON-Dateien + PDF-Vorlagen
- **Nur** Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien,
PDF-Auftragsvorlagen (inkl. PDF-Dateien + Feldzuordnungen)
- User entpackt das ZIP nach `backend/factory-defaults/`
- **Import-Script:** `backend/scripts/seed-factory-defaults.ts`
- Liest alle Dateien aus `backend/factory-defaults/`
- Bei mehreren Dateien: automatisches Mergen (per unique name)
- Nutzt Prisma `upsert` → idempotent, kann mehrfach ausgeführt werden
- PDF-Dateien werden nach `uploads/` kopiert + Pfade in DB aktualisiert
- Aufruf: `npm run seed:defaults`
- **Init-DB Integration:** Script wird optional beim Setup mit ausgeführt
wenn der Ordner existiert
---
## ✅ Erledigt
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
- Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
- Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
- Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download über Browser
- Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
- Ordner `backend/factory-defaults/` gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) wie bisher