Compare commits

...

2 Commits

Author SHA1 Message Date
duffyduck 4c65353917 docs: Factory-Defaults Import/Export-Anleitung in READMEs
Haupt-README.md: neuer Abschnitt mit Abgrenzung zu Datenbank-Backup, Schritt-
für-Schritt-Anleitung für Export und Import, Idempotenz-Hinweis, Berechtigungen.

backend/factory-defaults/README.md: ausführliche Referenz mit Struktur-Beispielen
aller JSON-Dateien (Provider, CancellationPeriod, ContractDuration,
ContractCategory, PdfTemplate), Teil-Import-Anleitung, Merge-Beispiele.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:19:02 +02:00
duffyduck ad49b92ee9 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>
2026-04-23 14:10:12 +02:00
14 changed files with 1241 additions and 40 deletions
+117
View File
@@ -916,6 +916,123 @@ Folgende Felder werden in Audit-Logs gefiltert:
- API-Response wird nicht blockiert
- Before/After-Werte über Prisma Middleware
## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups:
### Abgrenzung
| | Factory-Defaults | Datenbank-Backup |
|---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ |
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
> **Kurz:** Factory-Defaults = reine Kataloge, Backup = alles.
### Export (Installation A → ZIP)
1. **Einstellungen** → **Factory-Defaults** öffnen
2. Übersicht prüfen (Anzahl pro Kategorie)
3. Button **„Factory-Defaults exportieren"** klicken
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
**ZIP-Struktur:**
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version + Datum + Counts
├── providers/
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung)
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung)
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...)
└── pdf-templates/
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen
└── *.pdf # Die eigentlichen PDF-Dateien
```
Die ZIP kann an andere Installationen weitergegeben werden
(Partner, Test-System, neue Installation).
### Import (ZIP → Installation B)
1. ZIP herunterladen bzw. erhalten
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
3. Im Backend-Verzeichnis ausführen:
```bash
npm run seed:defaults
```
**Beispiel-Output:**
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Mehrere ZIPs kombinieren
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen
JSON-Dateien werden automatisch gemerged:
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus Verivox-Paket
check24.json # 30 Anbieter aus Check24-Paket
eigene.json # 5 eigene Anbieter
```
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch.
### Idempotenz
Das Script nutzt ausschließlich Prisma `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (per unique Key: `name`, `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` also beliebig oft ausführen, ohne Datenverlust
oder Duplikate.
### PDF-Dateien beim Import
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt.
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue
ersetzt.
### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
### Typischer Einsatzzweck
- **Neue Installation aufsetzen**: Eine Kollegen-ZIP importieren und sofort mit
gepflegtem Anbieter- und Vorlagenkatalog loslegen
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
(die anderen Ordner einfach aus der ZIP entfernen vor dem Entpacken)
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
## Lizenz
MIT
+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
+256
View File
@@ -0,0 +1,256 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
Siehe auch den Abschnitt „Factory-Defaults" in der Haupt-[README.md](../../README.md)
für einen Gesamtüberblick und die Abgrenzung zum Datenbank-Backup.
---
## Inhalt
```
backend/factory-defaults/
├── providers/
│ └── providers.json # Anbieter inkl. Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen
│ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
└── pdf-templates/
├── pdf-templates.json # Metadaten + Feldzuordnungen
└── *.pdf # PDF-Vorlagen-Dateien
```
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
---
## Export (aus einer bestehenden Installation)
1. Im CRM als Admin einloggen
2. **Einstellungen****Factory-Defaults**
3. Auf **„Factory-Defaults exportieren"** klicken
4. Die heruntergeladene ZIP (`factory-defaults-YYYY-MM-DD.zip`) speichern
### Inhalt der ZIP
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version, Datum, Einträge pro Kategorie
├── providers/providers.json
├── contract-meta/cancellation-periods.json
├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.json
├── pdf-templates/pdf-templates.json
└── pdf-templates/*.pdf
```
Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme,
neue Installationen oder Partner-Setups.
---
## Import (in eine andere Installation)
### Schritt-für-Schritt
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
Unterordnerstruktur beibehalten
3. **Script ausführen:**
```bash
cd backend
npm run seed:defaults
```
4. **Ausgabe prüfen** bei Erfolg:
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Idempotenz
Das Script nutzt ausschließlich `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (match per unique key: `name` / `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` **beliebig oft ausführen** kein Datenverlust,
keine Duplikate.
### Was passiert mit den PDF-Dateien?
Die PDFs aus `pdf-templates/*.pdf` werden beim Import nach
`backend/uploads/pdf-templates/` kopiert (mit eindeutigem Zeitstempel-Suffix).
Die Pfade in der DB werden automatisch auf die neue Kopie gesetzt.
Beim Re-Import einer bereits existierenden Vorlage wird die alte Datei in `uploads/`
entsorgt und durch die neue ersetzt.
---
## Mehrere Exporte mergen
Wenn du mehrere ZIPs hast (z.B. "Verivox-Paket", "Check24-Paket", "eigene"), kannst
du die JSON-Dateien frei benennen und in einen Ordner legen. Das Script liest
alle `*.json` im jeweiligen Unterordner und merged den Inhalt zusammen.
**Beispiel:**
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus dem Verivox-Paket
check24.json # 30 Anbieter aus dem Check24-Paket
eigene.json # 5 eigene, firmenspezifische Anbieter
contract-meta/
standard.json # Standard-Kündigungsfristen + Laufzeiten + Kategorien
pdf-templates/
ewe-paket.json # EWE-Vorlage
moon-paket.json # Moon-Vorlage
ewe-auftrag.pdf
moon-formular.pdf
```
Bei gleichem Unique-Key (z.B. `providers.name: "EWE"` in mehreren Dateien) gewinnt
der zuletzt gelesene Eintrag.
---
## Teil-Import (nur Kategorien auswählen)
Falls du nur einen Teil importieren willst (z.B. nur PDF-Vorlagen ohne Anbieter),
lösche oder verschiebe einfach die nicht gewünschten JSON-Dateien, bevor du das
Script ausführst. Das Script überspringt Kategorien ohne Dateien ohne Fehler.
**Beispiel: nur PDF-Vorlagen:**
```
backend/factory-defaults/
pdf-templates/ # nur diesen Ordner behalten
pdf-templates.json
*.pdf
```
---
## Struktur-Referenz (für manuelle Pflege)
### `providers/providers.json`
Array von Providern, jeweils inkl. zugehöriger Tarife:
```json
[
{
"name": "EWE",
"portalUrl": "https://www.ewe.de/privatkunden/meine-ewe/login",
"usernameFieldName": "username",
"passwordFieldName": "password",
"isActive": true,
"tariffs": [
{ "name": "EWE Zuhause Strom", "isActive": true },
{ "name": "EWE Zuhause Gas", "isActive": true }
]
}
]
```
**Unique Key:** `name`
### `contract-meta/cancellation-periods.json`
```json
[
{ "code": "14T", "description": "14 Tage", "isActive": true },
{ "code": "1M", "description": "1 Monat", "isActive": true },
{ "code": "3M", "description": "3 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-durations.json`
```json
[
{ "code": "12M", "description": "12 Monate", "isActive": true },
{ "code": "24M", "description": "24 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-categories.json`
```json
[
{
"code": "ELECTRICITY",
"name": "Strom",
"icon": "Zap",
"color": "#FFC107",
"sortOrder": 1,
"isActive": true
}
]
```
**Unique Key:** `code`
### `pdf-templates/pdf-templates.json`
```json
[
{
"name": "EWE Auftragsformular",
"description": "Auftrag für Glasfaser-Anschluss",
"providerName": "EWE",
"originalName": "EWE-Auftrag-Privat.pdf",
"fieldMapping": {
"Vorname": "customer.firstName",
"Nachname": "customer.lastName",
"Strasse": "address.streetFull",
"PLZ": "address.postalCode",
"Ort": "address.city"
},
"phoneFieldPrefix": "Rufnummer",
"maxPhoneFields": 8,
"isActive": true,
"pdfFilename": "EWE_Auftragsformular.pdf"
}
]
```
**Unique Key:** `name`
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
---
## Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
---
## Git & Versionierung
Dieser Ordner ist in `.gitignore` eingetragen (außer `.gitkeep` und `README.md`),
damit firmen-spezifische Exporte nicht versehentlich ins Repo kommen.
Wenn du **öffentlich teilbare Katalog-Pakete** versionieren willst, lege sie
außerhalb dieses Ordners ab (z.B. in einem eigenen Repository) und kopiere sie
bei Bedarf hierher.
+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
+2
View File
@@ -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() {
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
<Route path="settings/database-backup" element={<DatabaseBackup />} />
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />
+18 -1
View File
@@ -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() {
</div>
</div>
</Link>
<Link
to="/settings/factory-defaults"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<PackageCheck className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Factory-Defaults
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Stammdaten-Kataloge (Anbieter, Tarife, PDF-Vorlagen) exportieren ohne Kundendaten.</p>
</div>
</div>
</Link>
{hasPermission('audit:read') && (
<Link
to="/settings/audit-logs"
@@ -0,0 +1,225 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ArrowLeft,
Download,
Package,
Info,
Loader2,
Check,
AlertCircle,
Building2,
Tag,
Clock,
Calendar,
FileType,
FileText,
} from 'lucide-react';
import api from '../../services/api';
interface PreviewCounts {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
}
export default function FactoryDefaults() {
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(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 (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Factory-Defaults</h1>
</div>
</div>
{/* Info-Box */}
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 space-y-1">
<p className="font-medium">Was sind Factory-Defaults?</p>
<p>
Das sind <strong>reine Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie
exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu
verwenden.
</p>
<p className="pt-1">
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, Emails
oder Einstellungen dafür gibt es den separaten{' '}
<Link to="/settings/database-backup" className="underline">
Datenbank-Backup
</Link>
.
</p>
</div>
</div>
<Card title="Export" className="mb-6">
<p className="text-sm text-gray-600 mb-4">
Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und
entpacke den Inhalt in einer anderen Installation unter{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>
, dann dort{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>{' '}
ausführen.
</p>
{isLoading ? (
<div className="flex items-center gap-2 text-gray-500 py-4">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Lade Übersicht</span>
</div>
) : counts ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
{sections.map((s) => (
<div
key={s.label}
className="border border-gray-200 rounded-lg p-3 flex items-center gap-3"
>
<s.icon className={`w-5 h-5 ${s.color}`} />
<div>
<div className="text-sm text-gray-500">{s.label}</div>
<div className="text-xl font-bold">{s.count}</div>
</div>
</div>
))}
</div>
) : null}
<div className="flex items-center gap-3">
<Button onClick={handleExport} disabled={downloading}>
{downloading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
ZIP wird erstellt
</>
) : downloadDone ? (
<>
<Check className="w-4 h-4 mr-2" />
Heruntergeladen
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Factory-Defaults exportieren
</>
)}
</Button>
</div>
{downloadError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{downloadError}</span>
</div>
)}
</Card>
<Card title="Import">
<div className="space-y-3 text-sm text-gray-600">
<p>
Der Import läuft über ein Kommandozeilen-Script dadurch bleibt klar, was wann
passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb.
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>
ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>{' '}
entpacken
</li>
<li>
Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged)
</li>
<li>
Im Backend-Ordner:{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>
</li>
</ol>
<p className="pt-2">
Das Script läuft <strong>idempotent</strong> gleiche Einträge werden per
unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden.
</p>
</div>
</Card>
</div>
);
}