vcard4reseller/docs/KONZEPT.md
Thomas Peterson b52d696cc5 Druckdaten: Hintergrund-PDF (VDP) & eingebettete eigene Schriften
- Renderer auf FPDI (setasign/fpdi) umgestellt: Kunden-PDF wird als
  Seitenhintergrund importiert, nur dynamische Felder werden überlagert
  (Variable Data Printing); bei Hintergrund keine eigenen Schnittmarken
- Eigene Schriften (TTF/OTF) per TCPDF_FONTS::addTTFfont eingebettet,
  fontFamily pro Element; DejaVu-TTF im PHP-Image
- CardTemplate: backgroundPath + fonts; Renderer color() unterstützt {hex}
- CardAssetUploadController: Upload/Delete Hintergrund-PDF + Schrift-Upload,
  Speicher in var/storage/cards/{companyId} (außerhalb Webroot)
- Editor-GET liefert hasBackground + fonts
- Migration robust gegen MariaDB json_valid-CHECK (nullable -> '[]' -> NOT NULL)
- Konzept §13 ergänzt

Verifiziert: Kunden-Hintergrund + dynamische Felder + eingebettete Serifenschrift
+ QR; /FontFile im PDF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:23:41 +02:00

394 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# vcard4reseller — Konzept & Datenmodell
> White-Label-Plattform für digitale Visitenkarten (PrintShopCreator).
> Reseller (Druckereien/Agenturen) bieten ihren Firmenkunden ein zentrales
> Identity-Management an: ein Mitarbeiterprofil als *Single Source of Truth*,
> das in alle digitalen und gedruckten Ausgabekanäle synchronisiert wird.
**Status:** Konzeptphase — noch kein Code.
**Stack-Entscheidung:** Symfony (API Platform, JSON-API) + eigenständige Vue 3 SPA (Vite), MariaDB. Architektur **Variante A** (SPA + getrennte API).
---
## 1. Ziele & Leitprinzipien
1. **Single Source of Truth:** Eine Änderung am Mitarbeiterprofil propagiert in *alle* Kanäle (vCard, Wallet, Landingpage, NFC, QR, Druckdaten, E-Mail-Signatur).
2. **Mandantenfähigkeit (Multi-Tenancy):** Strikte Datentrennung pro Reseller und pro Firmenkunde.
3. **White-Label:** Jeder Reseller / Firmenkunde tritt unter eigener Domain & eigenem Branding auf.
4. **Wiederkehrender Umsatz:** Reseller zahlt Plattformgebühr (Paket), Firmenkunde zahlt Reseller (eigene Preisgestaltung).
5. **Öffentliche Profilseiten** müssen schnell & SEO-fähig sein → serverseitig gerendert (Twig), getrennt von den Dashboards (Vue-SPA).
---
## 2. Rollen & Berechtigungen
| Rolle | Symfony-Role | Scope | Kann |
|-------|--------------|-------|------|
| Plattform-Admin | `ROLE_PLATFORM_ADMIN` | global | Reseller anlegen/verwalten, Plattform-Pakete, Abrechnung gegenüber Resellern, Demo-Leads |
| Reseller-Admin | `ROLE_RESELLER_ADMIN` | 1 Reseller | Firmenkunden + Domains + Branding anlegen, Preise definieren, Druckaufträge, Reseller-Mitarbeiter |
| Firmen-Admin | `ROLE_COMPANY_ADMIN` | 1 Company | Standorte, Mitarbeiter/Profile, Templates der Firma, NFC/QR ausrollen |
| Mitarbeiter | `ROLE_EMPLOYEE` | 1 Profil | eigenes Profil pflegen (sofern Firma das erlaubt) |
| Öffentlicher Besucher | — | — | Profilseite ansehen, vCard/Wallet herunterladen, NFC/QR scannen |
**Hierarchie der Mandanten:** `Plattform → Reseller → Company → Location → Employee/Profil`
Durchsetzung über:
- **Doctrine-Filter** (automatisches Scoping nach `reseller_id` / `company_id` je nach eingeloggtem Kontext)
- **Security Voters** (z. B. `ProfileVoter`, `CompanyVoter`) für feingranulare Aktionen
- API-Platform `security`-Attribute pro Resource & Operation
---
## 3. Architektur
```
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ Vue 3 SPA (Vite) │ │ Öffentliche Profilseiten │
│ - Reseller-Dashboard │ │ (Symfony + Twig, SSR, SEO) │
│ - Firmen-Dashboard │ │ /{slug}, /c/{company}/{slug} │
│ - Mitarbeiter-Self-Svc │ │ vCard-Download, Wallet, QR-Ziel │
└───────────┬──────────────┘ └─────────────────┬────────────────┘
│ JSON (JWT/Session) │
▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Symfony Backend │
│ ├─ API Platform (REST/JSON, OpenAPI) │
│ ├─ Security (JWT/LexikJWT od. Session), Voters, Doctrine-Filter │
│ ├─ Domain Services (ProfileSync, ChannelGenerator) │
│ ├─ Messenger (async): Wallet-Pass, Druckdaten-PDF, QR/NFC, E-Mail │
│ └─ Doctrine ORM │
└───────────┬───────────────────────┬───────────────────┬──────────────┘
▼ ▼ ▼
MariaDB Object-Storage externe Dienste
(Mandantendaten) (Assets, PDFs, Pässe) (Apple/Google Wallet,
Payment/Stripe, Mail)
```
**Generierungs-Pipeline (ChannelGenerator):** Bei jeder Profiländerung wird ein
`ProfileUpdated`-Event ausgelöst → Messenger-Handler regenerieren die abgeleiteten
Artefakte (vCard-String, Wallet-Pass, QR/Kurz-URL, Druck-PDF, E-Mail-Signatur-HTML)
und legen sie versioniert ab. Öffentliche Endpunkte liefern immer den aktuellen Stand.
---
## 4. Datenmodell (Entitäten)
### Kernentitäten
- **User** — Auth-Identität. Felder: `email`, `password`, `roles[]`, `status`, `lastLogin`. Verknüpft optional mit `reseller_id`, `company_id`, `employee_id` (je nach Rolle).
- **Reseller** — oberster Mandant. Felder: `name`, `slug`, `primaryDomain`, Branding-Defaults, `status`, `platformPlan_id`. Hat Limits aus dem Plattform-Paket.
- **PlatformPlan** — Reseller-Pakete (Starter/Professional/Business). Felder: `name`, `pricePerMonth`, `maxProfiles`, `maxCompanies`, `features[]`.
- **ResellerSubscription** — Abo des Resellers bei der Plattform. Felder: `plan_id`, `status`, `startedAt`, `renewsAt`, `paymentRef` (Stripe).
- **Company** (Firmenkunde) — gehört zu Reseller. Felder: `name`, `slug`, Branding (Logo, Farben, Fonts), `defaultLocation_id`, `status`, `selfEditEnabled` (erlaubt Mitarbeiter-Self-Service generell). Domains ausgelagert in `Domain`.
- **Domain** — gehört zu Company. Felder: `hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`. Custom-Domains zeigen per A-Record auf unsere IP (siehe §11).
- **Location** (Standort) — gehört zu Company. Felder: Adresse, Geo, Telefon, Öffnungszeiten, Standort-spezifisches Branding-Override.
- **Employee / Profile** — gehört zu Company, optional Location. **Single Source of Truth.** Felder: Name, Titel, Position, Abteilung, Telefon(e), E-Mail, Foto, Bio, `slug` (öffentlich), `status`, `user_id` (optional, für Self-Service), `selfEditAllowed` (bool — Firmen-Admin gibt diesen Mitarbeiter frei), `editableFields` (optional: welche Felder der Mitarbeiter ändern darf).
- **ContactLink** — Social-/Web-Links eines Profils (Typ + URL + Reihenfolge). 1:n zu Employee.
### Branding & Vorlagen
- **Template** — Vorlage, scoped auf Reseller *oder* Company. Felder: `type` (`print_card` | `email_signature` | `landing_page` | `wallet_pass`), `name`, `config` (JSON), `assets`.
- **Asset** — hochgeladene Datei (Logo, Foto, Hintergrund). Felder: `path`, `mimeType`, `owner` (polymorph: reseller/company/employee).
### Ausgabekanäle (generierte Artefakte)
- **NfcTag** — physischer Tag. Felder: `uid`, `shortUrl`, `employee_id` (nullable bis Zuweisung), `status` (`unassigned`/`active`/`disabled`), `lastScanAt`.
- **QrCode** — generierter QR (statisch oder dynamische Redirect-URL). Felder: `target`, `imageAsset`, `employee_id`.
- **WalletPass** — Referenz auf Apple/Google-Pass. Felder: `provider` (`apple`|`google`), `serial`, `authToken` (Apple Web-Service-Auth), `passUrl`, `employee_id`, `lastGeneratedAt`. Siehe §12.
- **WalletDevice** — registriertes Apple-Gerät für Push-Updates (Apple-spezifisch). Felder: `deviceLibraryId`, `pushToken`, `serial` (→ WalletPass), `registeredAt`. Google braucht das nicht (Server-Push über die API).
- **GeneratedArtifact** (optional, generisch) — Cache abgeleiteter Outputs: `type` (vcard/print_pdf/signature_html), `employee_id`, `payload`/`fileRef`, `generatedAt`, `version`.
### Vertrieb & Abrechnung
- **Invoice** — Rechnung **Plattform→Reseller**. Felder: `reseller_id`, `amount`, `period`, `status`, `pdfRef`. (Reseller→Company-Rechnungen vorerst nicht Teil der Plattform, siehe §8.)
- ~~**CompanySubscription / PricePlan**~~ — *zurückgestellt:* Reseller↔Company-Abrechnung läuft außerhalb der Plattform.
- **PrintOrder** — Druckauftrag. Felder: `company_id`, `status` (`new`/`in_production`/`shipped`), `items[]` (je Employee), `printDataRef`.
### Marketing / Sonstiges
- **DemoRequest** — Lead vom Marketing-Formular (`/demo`). Felder: Name, Firma, E-Mail, Telefon, Nachricht, `status`.
- **AuditLog** — Nachvollziehbarkeit kritischer Aktionen (wer/was/wann).
---
## 5. ER-Diagramm
```mermaid
erDiagram
PlatformPlan ||--o{ Reseller : "definiert Limits"
Reseller ||--|| ResellerSubscription : hat
Reseller ||--o{ Company : verwaltet
Reseller ||--o{ User : "Reseller-Staff"
Reseller ||--o{ Template : "Defaults"
Company ||--o{ Location : hat
Company ||--o{ Domain : "Sub-/Custom-Domain"
Company ||--o{ Employee : beschäftigt
Company ||--o{ User : "Firmen-Admins"
Company ||--o{ Template : "eigene"
Location ||--o{ Employee : "zugeordnet"
Employee ||--o{ ContactLink : hat
Employee ||--o| NfcTag : verknüpft
Employee ||--o{ QrCode : hat
Employee ||--o| WalletPass : hat
WalletPass ||--o{ WalletDevice : "Apple-Push"
Employee ||--o{ GeneratedArtifact : erzeugt
Employee ||--o| User : "Self-Service"
Company ||--o{ PrintOrder : bestellt
Reseller ||--o{ Invoice : "Plattform→Reseller"
```
---
## 6. API-Struktur (API Platform, grob)
Alle unter `/api`, JWT-geschützt, mandantengescoped. Beispiele:
| Resource | Operationen | Zugriff |
|----------|-------------|---------|
| `/api/resellers` | CRUD | Platform-Admin |
| `/api/companies` | CRUD | Reseller-Admin (eigene), Platform-Admin |
| `/api/locations` | CRUD | Company-/Reseller-Admin |
| `/api/employees` | CRUD | Company-Admin; GET self für Employee |
| `/api/employees/{id}/regenerate` | POST | löst Kanal-Neugenerierung aus |
| `/api/templates` | CRUD | Reseller-/Company-Admin |
| `/api/nfc-tags` | CRUD + assign | Company-Admin |
| `/api/print-orders` | CRUD | Company-Admin / Reseller |
| `/api/demo-requests` | POST (öffentlich), GET (Admin) | Marketing |
**Öffentliche, nicht-API Endpunkte (Symfony/Twig, SSR):**
- `GET /{slug}` oder `https://{companyDomain}/{employeeSlug}` → Landingpage
- `GET /{slug}/vcard.vcf` → vCard-Download
- `GET /{slug}/wallet` → Wallet-Pass
- `GET /t/{nfcShortCode}` → NFC/QR-Redirect auf aktuelles Profil
---
## 7. Sicherheit & Mandantentrennung
- **Auth:** LexikJWTAuthenticationBundle (Stateless API) oder Session für SPA-Komfort. Refresh-Tokens.
- **Scoping:** Doctrine-`Filter`, der je nach eingeloggtem User automatisch `reseller_id`/`company_id` in alle Queries injiziert. Kein Datenleck zwischen Mandanten.
- **Voters:** `CompanyVoter`, `EmployeeVoter`, `TemplateVoter` für Aktion×Objekt-Prüfung.
- **Domain-Auflösung:** Middleware ermittelt aus Host-Header (Custom-Domain/Subdomain) den Mandantenkontext für öffentliche Seiten.
- **Datenschutz:** DSGVO — Mitarbeiterdaten, Lösch-/Export-Funktion, AuditLog. Standort EU.
---
## 8. Billing-Modell
**Entscheidung:** Vorerst rechnet **nur die Plattform gegenüber dem Reseller** ab. Wie der Reseller seinerseits mit den Firmenkunden abrechnet, regelt er selbst — das ist *nicht* Teil der Plattform (Stand jetzt). Eine spätere optionale Reseller→Company-Fakturierung bleibt als Erweiterung vorgemerkt.
1. **Plattform → Reseller (umgesetzt):** Reseller wählt `PlatformPlan` (Starter 99 € / Professional 249 € / Business 599 €). Stripe-Abo, Limits (`maxProfiles`, `maxCompanies`) werden durchgesetzt.
2. **Reseller → Company (außerhalb der Plattform):** Reseller vereinbart Preise selbst. Plattform bietet höchstens einen **Umsatzrechner/Reporting** zur Orientierung, aber keine Rechnungsstellung. → Entität `CompanySubscription`/`Invoice` (Reseller→Company) wird **zurückgestellt** auf Phase „später, falls gewünscht".
3. **Umsatzrechner** (aus Marketing): Profile × Preis Plattformgebühr = Marge (rein informativ).
---
## 9. Roadmap / Phasen
**Phase 0 — Setup** (nach Konzept-Freigabe)
- Symfony-Projekt + API Platform + Doctrine + MariaDB
- Vue 3 SPA (Vite) Grundgerüst, Auth-Flow
- CI, Docker-Dev-Umgebung
**Phase 1 — Kern-Domäne & Auth**
- Entitäten: User, Reseller, Company, Location, Employee, ContactLink
- Rollen, Voters, Doctrine-Mandantenfilter
- Reseller-/Firmen-Dashboards (CRUD)
**Phase 2 — Öffentliche Profile**
- SSR-Landingpage, vCard-Download, QR-Code, Kurz-URL-Redirect
- Branding/Template-Engine (landing_page)
**Phase 3 — Ausgabekanäle**
- Wallet-Pässe (Apple/Google), E-Mail-Signaturen, Druckdaten-PDF
- NFC-Tag-Verwaltung & Zuweisung
- Messenger-Pipeline für async Generierung
**Phase 4 — Billing & Vertrieb**
- Plattform-Pakete + Stripe, Limit-Durchsetzung
- Reseller-Preisgestaltung, Umsatzrechner, PrintOrders, Invoices
**Phase 5 — Marketing-Site & Politur**
- Öffentliche Landingpage (vcard4reseller.de-Nachbau), Demo-Formular → DemoRequest
- Reporting, AuditLog, DSGVO-Tools
---
## 10. Geklärte Entscheidungen & offene Fragen
### Geklärt (2026-05-30)
1. **Domains:** Standard = **Subdomains** (`firma.reseller.de`). Firmenkunden können zusätzlich eine **eigene Domain** hinterlegen; sie müssen dann selbst per **A-Record auf unsere IP** zeigen. Plattform übernimmt Domain-Verifikation + TLS-Provisionierung (Let's Encrypt). Siehe §11.
2. **Self-Service:** **Möglich, aber muss vom Firmen-Admin freigegeben werden** — zweistufig: `Company.selfEditEnabled` (Firma erlaubt es generell) + `Employee.selfEditAllowed` (pro Mitarbeiter). Mitarbeiter bearbeitet nur freigegebene Felder.
3. **Billing:** Nur **Plattform → Reseller** (siehe §8). Reseller↔Company außerhalb der Plattform; spätere Erweiterung möglich.
### Noch offen
4. **Wallet-Pässe:** Apple Developer Account + Google Wallet API vorhanden? (Zertifikate erforderlich). White-Label-Frage: läuft der Apple-Pass unter *einem* zentralen Pass Type ID (Plattform) oder pro Reseller? Konzept dazu in §12.
5. **Druckdaten:** Welches Format erwarten die Druckereien (PDF/X, bestimmte Maße, Beschnitt)? Gibt es Vorlagen?
6. **Bestehende Daten/Branding:** Existieren Design-Assets/CI zur bestehenden vcard4reseller.de, die wir übernehmen?
---
## 11. Domain-Handling
**Subdomain (Default):** Jede Company bekommt `{company.slug}.{reseller.primaryDomain}`. Wildcard-DNS + Wildcard-TLS auf Reseller-Ebene → keine Aktion durch den Kunden nötig.
**Eigene Domain (optional):**
1. Firmen-Admin trägt seine Domain (`visitenkarte.firma.de`) im Dashboard ein → Status `pending`.
2. Plattform zeigt die einzutragende **IP (A-Record)** (+ optional CNAME) an.
3. Kunde setzt den DNS-Eintrag bei seinem Provider.
4. Plattform prüft periodisch (DNS-Lookup) → bei Treffer Status `verified`, dann **automatisches TLS-Zertifikat** (Let's Encrypt / ACME).
5. Reverse-Proxy/Router löst den `Host`-Header → Mandantenkontext (Company) auf.
**Datenmodell-Ergänzung** an `Company` bzw. neue Entität **`Domain`**:
`hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`, `company_id`.
---
## 12. Wallet-Pässe & Kontakt-Synchronisation
### Das Grundproblem
Eine heruntergeladene **vCard (.vcf) ist ein Schnappschuss**: Einmal in der Kontakte-App gespeichert, aktualisiert sie sich nicht mehr. Ändert ein Mitarbeiter seine Nummer, haben alle Empfänger veraltete Daten. Das ist eine OS-Einschränkung. Es gibt drei Wege zu „aktuellen Daten":
| Weg | Echter Auto-Sync? | Wo landet es? | Aufwand |
|-----|-------------------|---------------|---------|
| **Wallet-Pass** (Apple/Google) | **Ja, over-the-air** | Wallet-App (nicht Kontakte) | hoch (Zertifikate) |
| **Link behalten** (QR/NFC/Kurz-URL) | Daten aktuell *beim Öffnen*, vCard wird live generiert | Browser → Kontakt bei Bedarf neu | ✅ umgesetzt (Phase 2) |
| **CardDAV** | Ja, auf Kontakt-Ebene | native Kontakte-App | mittel, für Einzel-Empfänger unpraktisch (account-basiert) |
**Fazit:** Den einzigen echten „Push in eine bereits gespeicherte Karte" liefert der **Wallet-Pass**. Der **Link** (unsere Profilseite) ist der pragmatische Standard: nicht die Daten, sondern der Link wird gespeichert; die vCard erzeugen wir bei jedem Abruf frisch.
### Apple Wallet (PassKit)
- `.pkpass` = ZIP aus `pass.json` + Bildern + `manifest.json` + **PKCS#7-Signatur** (Pass Type ID-Zertifikat + Apple-WWDR-Cert).
- Pass-Stil `generic` (Visitenkarte): Name / Position / Firma / Telefon / E-Mail / Link.
- PHP: z. B. `includable/php-pkpass`, gekapselt in einem `WalletService`.
- **Sync (Push):** Pass enthält `webServiceURL` + `authenticationToken`.
1. Nutzer fügt Pass hinzu → Gerät **registriert** sich (→ `WalletDevice`).
2. Profil ändert sich → **leerer APNs-Push** an registrierte Geräte.
3. Gerät holt aktualisierten Pass von unserer **PassKit-Web-Service-API** ab.
- Nötige Endpunkte (PassKit Web Service): `register`, `unregister`, `list serials`, `latest pass`.
### Google Wallet
- REST-API + Service-Account (kostenlos). *Generic*-Pass-**Klasse** + pro Mitarbeiter ein **Objekt**.
- Hinzufügen via „Add to Google Wallet"-Link (signiertes JWT).
- **Sync:** Objekt server-seitig per API **patchen** → Google pusht selbst. Kein APNs, keine Geräte-Registrierung. → einfacher als Apple, daher **als erster Wallet-Kanal empfohlen**.
### Einbettung in die Architektur
```
Employee geändert ──► ProfileUpdated-Event ──► Messenger (async)
├─ Apple: Pass neu signieren + APNs-Push an WalletDevices
└─ Google: Wallet-Objekt per API patchen (Google pusht selbst)
```
Neue Bausteine: `WalletPass`/`WalletDevice` (Datenmodell §4), `WalletService` (Pass bauen/signieren/patchen), `ApplePassController` (Web-Service-Endpunkte), Messenger-Handler am `ProfileUpdated`-Event (gleicher Trigger wie `Employee::touch()`).
### White-Label-Überlegung
Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein zentraler Pass Type ID der Plattform für alle Reseller (einfacher, Branding über Pass-Felder/Logo) oder **(b)** pro Reseller ein eigener (aufwändig, jeder Reseller braucht Apple-Account). Empfehlung: **(a)** zentral, Reseller-/Firmen-Branding über Logo & Farben im Pass. → zu klären (offene Frage #4).
### Voraussetzungen (offen)
- Apple Developer Account + Pass Type ID + Zertifikat (`.p12`) — für Apple Wallet.
- Google-Cloud-Projekt + Wallet-API + Service-Account — für Google Wallet.
---
## 13. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
Reseller drucken für ihre Firmenkunden Visitenkarten und brauchen **druckfertige PDFs**. Das Layout variiert **pro Firma**. Später kommt **Briefpapier** (gleiches System, anderes Format) dazu.
### Festgelegte Entscheidungen (2026-05-31)
- **Farbraum/Qualität:** CMYK, Ziel **PDF/X-1a**, eingebettete Fonts.
- **Format:** 85×55 mm, **2 mm Beschnitt** (→ 89×59 mm Dokument), **Schnittmarken**, Sicherheitsabstand ~4 mm.
- **Seiten:** Vorder- **und** Rückseite (2 PDF-Seiten).
- **Ausgabe:** ein PDF pro Mitarbeiter (Druckbogen/Ausschießen später).
- **Layout-Definition:** **visueller Editor** im Dashboard → strukturierte Element-Definition (JSON), nicht HTML.
### Datenmodell
**`CardTemplate`** — gehört zu einer Company (`company = null` ⇒ globale Standardvorlage).
Felder: `name`, `widthMm` (85), `heightMm` (55), `bleedMm` (2), `safeMm` (4),
`front` (Element[]), `back` (Element[]), `type` (`card` | später `letterhead`), `company_id`.
**Element** (JSON): `type` (`field` | `text` | `image` | `qr` | `rect` | `line`),
`xMm`, `yMm`, `wMm`, `hMm`, `rotation`,
Typografie (`fontFamily`, `fontSizePt`, `bold`, `italic`, `align`, `lineHeight`),
`color` (CMYK `{c,m,y,k}` 0100), `fill` (CMYK),
`binding` (bei `field`: Mitarbeiter-Feld), `text` (bei `text`: statisch), `src` (bei `image`).
**Bindings** (Datenquelle = Mitarbeiterprofil): `firstName`, `lastName`, `fullName`,
`position`, `department`, `email`, `phone`, `mobile`, `company.name`, `profileUrl`,
`shortUrl`; Spezial: `qr` (Kurz-URL), `logo` (Firmen-Logo aus brandingConfig).
### Rendering-Pipeline
```
CardTemplate (JSON) + Employee + Branding
▼ TCPDF (Koordinaten in mm, CMYK, Fonts eingebettet)
89×59 mm Seite (Trim 85×55 + 2 mm Bleed), Schnittmarken, V/R = 2 Seiten
▼ (Finishing) Ghostscript → PDF/X-1a:2001 mit CMYK-Output-Intent (ICC, z. B. ISO Coated v2)
druckfertiges PDF
```
- **TCPDF**: exakte mm-Platzierung pro Element, `setColor`/`setTextColor` mit CMYK,
`cropMark()` an den Trim-Ecken, Bilder/QR als eingebettete Grafik, Fonts eingebettet.
- **PDF/X-1a-Finishing** (Ghostscript) als nachgelagerter Schritt — braucht `ghostscript`
+ ICC-Profil im Container. Bis dahin: valides CMYK-PDF mit Bleed + Schnittmarken.
### Endpunkte
- `GET /api/employees/{id}/card.pdf` — Einzelkarte (auth, Reseller/Firma; mandantengeprüft).
- später: Sammel-PDF / ausgeschossener Bogen je Firma; Anbindung an `PrintOrder`.
### Visueller Editor (SPA)
- Canvas im mm-Maßstab mit sichtbarem **Beschnitt-, Trim- und Sicherheitsrahmen**.
- Elemente per Drag&Drop; Eigenschaften-Panel (Position/Größe/Font/Farbe/Datenbindung).
- Tabs **Vorder-/Rückseite**; Live-Vorschau mit echten oder Beispiel-Daten.
- Speichern als `CardTemplate` (pro Firma); Default-Vorlage als Startpunkt.
### Briefpapier (später)
Gleiches `CardTemplate`-Modell mit `type = letterhead` und Format **A4**; der Renderer
ist format-agnostisch (Maße + Elementliste). Bindings identisch + ggf. Adressfeld/Faltmarken.
### Hintergrund-PDF (Variable Data Printing) & eigene Schriften
**Hintergrund-PDF:** Der Kunde gestaltet die Karte vollständig in seinem DTP-Programm
(InDesign/Illustrator: Logo, Farbflächen, Hintergrund, statische Texte) und exportiert
ein druckfertiges PDF (CMYK, mit Beschnitt). Dieses wird hochgeladen und dient als
**Hintergrund**; die Plattform legt darüber nur noch die **dynamischen Felder**
(Name, Position, Kontakt, QR). Klassisches *Variable Data Printing*.
- Technik: **FPDI** (`setasign/fpdi`) importiert die Seiten des Kunden-PDFs; der
Renderer (FPDI-Tcpdf) platziert sie als Seitenhintergrund (`useTemplate`) und
zeichnet die dynamischen Elemente darüber.
- Erwartung: Hintergrund-PDF hat die Maße Endformat + Beschnitt (`width+2·bleed` ×
`height+2·bleed`). Seite 1 = Vorderseite, optional Seite 2 = Rückseite.
- Bei Hintergrund-PDF: **keine** eigenen Schnittmarken/Beschnitt-Logik (der Kunde
liefert das); Element-Koordinaten weiterhin im Trim-Raum, Offset = `bleedMm`.
- Die Vorlage enthält dann nur noch dynamische Elemente (Felder/QR), die statischen
entfallen.
**Eigene Schriften:** TTF/OTF werden hochgeladen und per `TCPDF_FONTS::addTTFfont()`
**eingebettet** (Subset). `CardTemplate` führt eine Schriften-Liste (`family → Pfad`);
Elemente referenzieren eine `fontFamily` (Custom oder Core helvetica/times/courier).
Font-Embedding ist zugleich Voraussetzung für striktes **PDF/X**.
**Uploads/Datenmodell:** `CardTemplate.backgroundPath` (Pfad zum Kunden-PDF, nullable),
`CardTemplate.fonts` (`[{family, path}]`). Dateien liegen außerhalb des Webroots unter
`var/storage/cards/{companyId}/`. Upload-Endpunkte:
`POST /api/companies/{id}/card-template/background` (PDF) und `.../font` (TTF/OTF).