diff --git a/backend/.gitignore b/backend/.gitignore index e22acb5f..c640abba 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -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* diff --git a/backend/factory-defaults/.gitkeep b/backend/factory-defaults/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/backend/factory-defaults/README.md b/backend/factory-defaults/README.md new file mode 100644 index 00000000..b48e50e0 --- /dev/null +++ b/backend/factory-defaults/README.md @@ -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. diff --git a/backend/package.json b/backend/package.json index 481dce34..ec462cb1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/seed-factory-defaults.ts b/backend/scripts/seed-factory-defaults.ts new file mode 100644 index 00000000..a1a0d728 --- /dev/null +++ b/backend/scripts/seed-factory-defaults.ts @@ -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(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(items: T[], keyFn: (item: T) => string): T[] { + const map = new Map(); + for (const item of items) { + map.set(keyFn(item), item); + } + return Array.from(map.values()); +} + +async function seedProviders() { + const items = dedupe(readJsonArrays(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(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(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(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(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()); diff --git a/backend/src/controllers/factoryDefaults.controller.ts b/backend/src/controllers/factoryDefaults.controller.ts new file mode 100644 index 00000000..fab771a7 --- /dev/null +++ b/backend/src/controllers/factoryDefaults.controller.ts @@ -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' }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 59eea162..f8129469 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/routes/factoryDefaults.routes.ts b/backend/src/routes/factoryDefaults.routes.ts new file mode 100644 index 00000000..c54ba3fa --- /dev/null +++ b/backend/src/routes/factoryDefaults.routes.ts @@ -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; diff --git a/backend/src/services/factoryDefaults.service.ts b/backend/src/services/factoryDefaults.service.ts new file mode 100644 index 00000000..964303e6 --- /dev/null +++ b/backend/src/services/factoryDefaults.service.ts @@ -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/.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((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'), + }; + }), + }; +} + +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, + }, + }; + + 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', + }); + + // 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); + } + })(); + }); +} diff --git a/backend/todo.md b/backend/todo.md index bec27246..16a0c1e9 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66aadd4b..771a3687 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import PortalSettings from './pages/settings/PortalSettings'; import DeadlineSettings from './pages/settings/DeadlineSettings'; import EmailProviders from './pages/settings/EmailProviders'; import DatabaseBackup from './pages/settings/DatabaseBackup'; +import FactoryDefaults from './pages/settings/FactoryDefaults'; import AuditLogs from './pages/settings/AuditLogs'; import EmailLogPage from './pages/settings/EmailLogs'; import GDPRDashboard from './pages/settings/GDPRDashboard'; @@ -192,6 +193,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 600dd2b3..faa678ae 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import Card from '../components/ui/Card'; -import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit } from 'lucide-react'; +import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react'; export default function Settings() { const { hasPermission, developerMode, setDeveloperMode } = useAuth(); @@ -177,6 +177,23 @@ export default function Settings() { + +
+
+ +
+
+

+ Factory-Defaults + +

+

Stammdaten-Kataloge (Anbieter, Tarife, PDF-Vorlagen) exportieren – ohne Kundendaten.

+
+
+ {hasPermission('audit:read') && ( (null); + const [downloadDone, setDownloadDone] = useState(false); + + const { data: previewData, isLoading } = useQuery({ + queryKey: ['factory-defaults-preview'], + queryFn: async () => { + const res = await api.get<{ success: boolean; data: { counts: PreviewCounts } }>( + '/factory-defaults/preview', + ); + return res.data; + }, + }); + + const counts = previewData?.data?.counts; + + const handleExport = async () => { + setDownloading(true); + setDownloadError(null); + setDownloadDone(false); + + try { + const res = await api.get('/factory-defaults/export', { + responseType: 'blob', + }); + + const blob = new Blob([res.data as BlobPart], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const dateStr = new Date().toISOString().split('T')[0]; + a.href = url; + a.download = `factory-defaults-${dateStr}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + setDownloadDone(true); + setTimeout(() => setDownloadDone(false), 3000); + } catch (err) { + setDownloadError( + err instanceof Error ? err.message : 'Fehler beim Export', + ); + } finally { + setDownloading(false); + } + }; + + const sections = counts + ? [ + { icon: Building2, label: 'Anbieter', count: counts.providers, color: 'text-blue-600' }, + { icon: Tag, label: 'Tarife', count: counts.tariffs, color: 'text-indigo-600' }, + { icon: Clock, label: 'Kündigungsfristen', count: counts.cancellationPeriods, color: 'text-purple-600' }, + { icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' }, + { icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' }, + { icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' }, + ] + : []; + + return ( +
+
+ + + +
+ +

Factory-Defaults

+
+
+ + {/* Info-Box */} +
+ +
+

Was sind Factory-Defaults?

+

+ Das sind reine Stammdaten-Kataloge wie Anbieter, Tarife, + Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie + exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu + verwenden. +

+

+ Nicht enthalten sind Kundendaten, Verträge, Dokumente, Emails + oder Einstellungen – dafür gibt es den separaten{' '} + + Datenbank-Backup + + . +

+
+
+ + +

+ Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und + entpacke den Inhalt in einer anderen Installation unter{' '} + + backend/factory-defaults/ + + , dann dort{' '} + + npm run seed:defaults + {' '} + ausführen. +

+ + {isLoading ? ( +
+ + Lade Übersicht… +
+ ) : counts ? ( +
+ {sections.map((s) => ( +
+ +
+
{s.label}
+
{s.count}
+
+
+ ))} +
+ ) : null} + +
+ +
+ + {downloadError && ( +
+ + {downloadError} +
+ )} +
+ + +
+

+ Der Import läuft über ein Kommandozeilen-Script – dadurch bleibt klar, was wann + passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb. +

+
    +
  1. + ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '} + + backend/factory-defaults/ + {' '} + entpacken +
  2. +
  3. + Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged) +
  4. +
  5. + Im Backend-Ordner:{' '} + + npm run seed:defaults + +
  6. +
+

+ Das Script läuft idempotent – gleiche Einträge werden per + unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden. +

+
+
+
+ ); +}