- WalletService (dependency-frei): Google signierter RS256-„Save"-JWT-Link;
Apple .pkpass (pass.json + GD-Icons + manifest + PKCS#7 via openssl + zip).
Konfigurationsgesteuert (env), ohne Zugangsdaten deaktiviert.
- WalletController: /w/{code} Landing (Geräteerkennung + Buttons),
/w/{code}/qr.png, /apple.pkpass, /google (302). Adressierung via shortCode.
- Öffentliche Profilseite: QR-Bereich „Zur Wallet hinzufügen" (nur wenn
Provider konfiguriert + shortCode vorhanden).
- .env Wallet-Block (leer=aus), KONZEPT §12 + deploy/README dokumentiert.
Verifiziert: not-configured → ausgeblendet/404; mit Test-Zertifikaten valides
signiertes .pkpass + Google-Save-JWT. Produktiv: echte Apple-/Google-Creds nötig.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
34 KiB
Markdown
608 lines
34 KiB
Markdown
# 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`
|
||
|
||
### Mehrere Logins pro Ebene
|
||
|
||
Jede Ebene kann **mehrere Benutzer** haben (mehrere Plattform-, Reseller-, Firmen-Admins).
|
||
Technisch: `User` ist die Login-Identität (1 Person = 1 Login), `n` User pro
|
||
Reseller bzw. Company. Ein `User` ist optional mit einem `Employee` (Profil) verknüpft —
|
||
so wird aus einem Mitarbeiter mit Profil per **Rechtegruppen-Zuweisung** zusätzlich
|
||
ein eingeloggter Admin.
|
||
|
||
### Rechtegruppen (zuweisbare Rollen)
|
||
|
||
Die Rollen werden im UI als wählbare **Rechtegruppe** angeboten — beim Anlegen/Bearbeiten
|
||
eines Benutzers bzw. direkt in der Mitarbeiterliste („kein Login" | „Mitarbeiter" |
|
||
„Firmen-Admin" | …). Start = die vier festen Rollen; später optional **granulare
|
||
Berechtigungs-Gruppen** (eigene Rechte-Sets pro Mandant).
|
||
|
||
### Delegierte Rechtevergabe (Kernregel)
|
||
|
||
Jede Ebene vergibt Rechte **nur an oder unterhalb der eigenen Ebene** und **nur im
|
||
eigenen Mandanten-Teilbaum** — niemals nach oben, nie über Mandantengrenzen hinweg:
|
||
|
||
| Akteur | darf Rechtegruppe vergeben | Geltungsbereich |
|
||
|--------|----------------------------|-----------------|
|
||
| Plattform-Admin | Plattform-Admin, Reseller-Admin (+ darunter) | alle Mandanten |
|
||
| Reseller-Admin | Reseller-Admin (weitere im eigenen Reseller), Firmen-Admin, Mitarbeiter | nur eigener Reseller + dessen Firmen |
|
||
| Firmen-Admin | Firmen-Admin (weitere der eigenen Firma), Mitarbeiter | nur eigene Firma |
|
||
| Mitarbeiter | — | — |
|
||
|
||
D. h.: *wir* vergeben Reseller-Rechte, Reseller vergeben Firmen-Admin-Rechte, Firmen-Admins
|
||
vergeben Mitarbeiter-/weitere Firmen-Admin-Rechte. Same-Level-Vergabe (weitere Admins der
|
||
eigenen Ebene) ist erlaubt → ermöglicht mehrere Logins pro Ebene.
|
||
|
||
Durchsetzung über:
|
||
- **Doctrine-Filter / API-Platform-Query-Extension** (automatisches Scoping nach
|
||
`reseller_id` / `company_id` je nach eingeloggtem Kontext)
|
||
- **Security Voters** (`CompanyVoter`, `EmployeeVoter`) für Aktion×Objekt
|
||
- **`RoleAssignmentVoter`/-Service**: prüft bei jeder Rollen-/Rechtegruppen-Vergabe,
|
||
dass die Zielrolle ≤ der höchsten Rolle des Akteurs ist **und** der Ziel-Benutzer im
|
||
Mandanten-Teilbaum des Akteurs liegt (Schutz vor Privilege-Escalation)
|
||
- 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 (1 Login). Felder: `email`, `password`, `roles[]` (= Rechtegruppe), `status`, `lastLogin`. Verknüpft optional mit `reseller_id`, `company_id`, `employee_id`. **Mehrere User pro Reseller/Company** möglich; Rollenvergabe ist delegiert & scope-geprüft (siehe §2).
|
||
- **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.
|
||
|
||
---
|
||
|
||
### Umsetzung (implementiert)
|
||
|
||
QR-Code auf der **öffentlichen Profilseite** („Zur Wallet hinzufügen") → Landing
|
||
`/w/{shortCode}` mit Geräteerkennung und Apple-/Google-Buttons. Beides
|
||
konfigurationsgesteuert (env); ohne Zugangsdaten ausgeblendet/deaktiviert.
|
||
|
||
- `WalletService` (dependency-frei): Google = signierter RS256-„Save"-JWT-Link
|
||
(`pay.google.com/gp/v/save/{jwt}`, fat genericClasses+Objects); Apple = `.pkpass`
|
||
(pass.json + GD-Icons + manifest + **PKCS#7-Signatur** via `openssl_pkcs7_sign`, gezippt).
|
||
- `WalletController`: `GET /w/{code}` (Landing), `/w/{code}/qr.png`,
|
||
`/w/{code}/apple.pkpass`, `/w/{code}/google` (302). Adressierung über `shortCode`.
|
||
- **Konfig (env):** Apple = `APPLE_WALLET_PASS_TYPE_ID`, `_TEAM_ID`, `_CERT_PATH`,
|
||
`_KEY_PATH`, `_KEY_PASSWORD`, `_WWDR_PATH`, `_ORG_NAME`; Google =
|
||
`GOOGLE_WALLET_ISSUER_ID`, `GOOGLE_WALLET_SERVICE_ACCOUNT` (Pfad zur
|
||
service-account.json), `GOOGLE_WALLET_CLASS_SUFFIX`.
|
||
- **Offen (Sync/Push):** Apple PassKit-Web-Service (`register`/`unregister`/`latest`)
|
||
+ APNs + `WalletDevice`; Google Objekt-`patch`. Bisher nur Pass-Erstellung.
|
||
|
||
---
|
||
|
||
## 13. Produktkatalog (mehrere Produkttypen)
|
||
|
||
Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**.
|
||
Ein **Produkt** ist der Katalogeintrag, den eine Firma als druck-/ausrollbares
|
||
Erzeugnis bestellt und gestaltet. Start mit drei Typen: **Visitenkarte**,
|
||
**Namensschild**, **NFC-Karte**.
|
||
|
||
### Eigentümer & Sichtbarkeit
|
||
|
||
Produkte definieren **ausschließlich**:
|
||
|
||
| Definiert von | `reseller` | Sichtbar für |
|
||
|---------------|-----------|--------------|
|
||
| Plattform-Betreiber | `null` (global) | alle Reseller + deren Firmen |
|
||
| Reseller | eigener Reseller | nur eigener Mandant (Reseller + dessen Firmen) |
|
||
|
||
Firmen(-Admins) **wählen** aus den für sie sichtbaren Produkten (globale + die des
|
||
eigenen Resellers), legen aber **keine** Produkte an. Bearbeiten/Löschen darf jeweils
|
||
nur der Eigentümer (Plattform globale, Reseller die eigenen). Globale Produkte sind
|
||
für Reseller **read-only**.
|
||
|
||
### `kind` (Produktart) — legt Fähigkeiten & Defaults fest
|
||
|
||
| `kind` | Standardformat | Seiten | Druck | NFC |
|
||
|--------|----------------|--------|:----:|:---:|
|
||
| `business_card` (Visitenkarte) | 85 × 55 mm, 2 mm Bleed | V/R | ✓ | optional |
|
||
| `name_tag` (Namensschild) | 90 × 55 mm, 0 mm Bleed | nur V | ✓ | – |
|
||
| `nfc_card` (NFC-Karte) | 85,6 × 54 mm (ID-1), 2 mm Bleed | V/R | ✓ | ✓ |
|
||
|
||
`kind` ist ein festes Enum (Fähigkeiten = Druck-PDF und/oder NFC-Programmierung).
|
||
Sowohl Plattform als auch Reseller legen Produkte **dieser** Arten an (z. B. ein
|
||
Reseller-eigenes „Premium-Visitenkarte 90×50").
|
||
|
||
### Datenmodell
|
||
|
||
**`Product`** (`ResellerOwnedInterface`): `reseller_id` (nullable = global), `kind`,
|
||
`name`, `description`, `widthMm`, `heightMm`, `bleedMm`, `safeMm`, `sides` (1|2),
|
||
`nfcEnabled`, `printEnabled`, `active`, `sortOrder`, `createdAt`.
|
||
|
||
**`CardTemplate`** referenziert künftig ein **`product_id`**: das konkrete **Design
|
||
einer Firma für ein Produkt** (eine Firma kann je Produkt ein Design haben). Format
|
||
(Maße/Bleed/Seiten) wird vom Produkt geerbt; der Renderer bleibt formatagnostisch.
|
||
|
||
### Endpunkte
|
||
|
||
- `GET /api/products` — sichtbare Produkte (global + eigener Reseller), API-Platform-scoped.
|
||
- `POST/PATCH/DELETE /api/products/{id}` — nur Eigentümer (Voter).
|
||
- Editor: `GET/PUT /api/companies/{id}/card-template?product={productId}` (Design je Produkt).
|
||
|
||
### Renderer/NFC je Produktart
|
||
|
||
- `business_card` / `nfc_card` / `name_tag` nutzen denselben `CardPdfRenderer`
|
||
(Maße + Elementliste aus Produkt + Design). Beschnitt/Schnittmarken nur wenn `bleedMm > 0`.
|
||
- `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra
|
||
(Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14.
|
||
|
||
### Bestellungen (PrintOrder)
|
||
|
||
Firmen-Admins **bestellen** Produkte für ihre Mitarbeiter; der Reseller (Druckshop)
|
||
**wickelt ab** (produziert, versendet). Beispiel: *10 Visitenkarten + 5 NFC-Karten für
|
||
verschiedene Mitarbeiter*.
|
||
|
||
**Datenmodell:**
|
||
|
||
- **`PrintOrder`** (`ResellerOwnedInterface` via `company.reseller`): `number`
|
||
(kurze Bestellnr.), `company`, `status`, `note`, `createdBy` (Employee), `createdAt`,
|
||
`items[]`.
|
||
- **`OrderItem`**: `product`, `employee` (für wen — personalisiert), `quantity` (Auflage).
|
||
|
||
**Status-Workflow:** `new` → `in_production` → `shipped` → `completed`; `cancelled` quer.
|
||
- Firmen-Admin: legt Bestellung an (`new`), kann sie **stornieren** solange `new`.
|
||
- Reseller-/Plattform-Admin: schiebt den Status vorwärts (Produktion/Versand/erledigt),
|
||
kann jederzeit stornieren.
|
||
|
||
**Druckdaten:** je Position liefert das bestehende `GET /api/employees/{id}/card.pdf?product={productId}`
|
||
das druckfertige PDF (Mitarbeiter × Produkt). Sammel-/Bogen-PDF später.
|
||
|
||
**Endpunkte (`OrderController`, mandantengeprüft):**
|
||
- `GET /api/orders` — Liste (Firma: eigene; Reseller: alle seiner Firmen; Plattform: alle).
|
||
- `GET /api/orders/{id}` — Detail inkl. Positionen + PDF-Links.
|
||
- `POST /api/orders` — anlegen (Firmen-Admin; `items[]` = Produkt+Mitarbeiter+Menge).
|
||
- `PATCH /api/orders/{id}/status` — Status setzen (Reseller wickelt ab / Firma storniert).
|
||
|
||
**Sichtbarkeit:** Produkt muss im Mandanten sichtbar sein (global oder eigener Reseller),
|
||
Mitarbeiter muss zur bestellenden Firma gehören. Preise/Abrechnung = spätere Ausbaustufe.
|
||
|
||
---
|
||
|
||
## 14. 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}` 0–100), `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).
|
||
|
||
---
|
||
|
||
## 15. Zeiterfassung (Modul „Kommen/Gehen")
|
||
|
||
Erweiterung über die digitale Visitenkarte hinaus: eine **Arbeitszeiterfassung**
|
||
für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist
|
||
die gemeinsame Klammer — dieselbe Identität (`Employee`), dieselben Standorte,
|
||
dieselbe NFC/QR-Infrastruktur. Positioniert die Plattform vom „Identity/Print-Tool"
|
||
hin zur **Mitarbeiter-Plattform** (ein Datensatz, mehrere Module).
|
||
|
||
### Markttreiber
|
||
EuGH 2019 + BAG 2022 → **Pflicht zur Arbeitszeiterfassung** in DE. SMB-Kunden
|
||
suchen einfache Lösungen → starkes Verkaufsargument; NFC-Terminals/Badges sind
|
||
zusätzlicher **Hardware-Umsatz** für den Druckshop.
|
||
|
||
### Erfassungswege
|
||
- **Web/App** (authentifizierter Mitarbeiter über sein `User`-Konto): Button
|
||
„Kommen / Gehen / Pause" im Dashboard bzw. Handy-Browser. Quelle `web`/`app`.
|
||
- **Kiosk** (geteiltes Standort-Gerät, per Geräte-Token): Mitarbeiter identifiziert
|
||
sich per **NFC-Tap** (`shortCode`) oder **PIN**. Quelle `kiosk`.
|
||
- NFC/QR nutzt die bestehende `shortCode`/`/t/`-Infrastruktur. *Hinweis:* da der
|
||
Profil-`shortCode` halb-öffentlich ist, am Kiosk zusätzlich **PIN** empfehlen.
|
||
|
||
### Datenmodell
|
||
|
||
**`TimeEntry`** — append-only Stempel-Ereignis (revisionssicher, **kein** Update/Delete):
|
||
`employee`, `type` (`clock_in`|`clock_out`|`break_start`|`break_end`),
|
||
`occurredAt` (Stempelzeit), `recordedAt` (Speicherzeit, immutable),
|
||
`source` (`web`|`app`|`kiosk`|`nfc`|`qr`|`manual`), `location` (nullable),
|
||
`createdBy` (User), `note`, `status` (`active`|`corrected`|`voided`),
|
||
`correctsEntry` (self-ref, nullable), `reason` (bei Korrektur/Storno).
|
||
|
||
**`KioskDevice`** — registriertes Standort-Terminal: `name`, `token`, `location`,
|
||
`status`, `lastSeenAt`.
|
||
|
||
**`Employee`-Ergänzung:** `clockPin` (optional, für Kiosk/App-Identifikation).
|
||
|
||
**Korrekturprinzip:** Originale bleiben erhalten; eine Korrektur erzeugt einen
|
||
**neuen** `TimeEntry` (`type=correction`-Bezug via `correctsEntry`) mit Grund +
|
||
Bearbeiter, der alte wird als `corrected` markiert. Der „gültige" Stundenzettel
|
||
wird aus den `active`-Einträgen berechnet. Jede Markierung ist protokolliert →
|
||
nachvollziehbar/manipulationssicher.
|
||
|
||
### Endpunkte (grob)
|
||
- `POST /api/time-clock/punch` — eingeloggter Mitarbeiter stempelt (Typ ermittelt
|
||
oder explizit), Quelle web/app.
|
||
- `POST /api/kiosk/punch` — Geräte-Token + Mitarbeiter-Kennung (NFC-`shortCode`/PIN).
|
||
- `GET /api/time-entries` — mandantengescoped (Mitarbeiter: eigene; Firmen-Admin: alle).
|
||
- `GET /api/timesheets?employee=&period=` — Aggregation (Tag/Woche, Überstunden, je Standort).
|
||
- `POST /api/time-entries/{id}/correct` — Firmen-Admin, hängt Korrektur an (Audit).
|
||
|
||
### Rollen
|
||
- **Mitarbeiter**: stempelt, sieht eigene Zeiten (Transparenzpflicht).
|
||
- **Firmen-Admin**: Stundenzettel, Korrekturen (mit Grund/Audit), Export, Kiosk-Geräte.
|
||
- **Reseller/Plattform**: white-label Einstellungen, Limits.
|
||
|
||
### Compliance (bewusst zu behandeln)
|
||
- **Revisionssicherheit**: append-only + protokollierte Korrekturen (s. o.).
|
||
- **ArbZG**: Pausen/Höchstarbeits-/Ruhezeiten erfassbar bzw. warnend.
|
||
- **DSGVO + BetrVG §87** (Mitbestimmung): kein heimliches Tracking, Einsicht für
|
||
Mitarbeiter, Standort EU, Lösch-/Exportkonzept.
|
||
|
||
### Auswertung & Export
|
||
Stundenzettel (Tag/Woche/Monat), Überstunden, Pausensummen je Mitarbeiter/Standort;
|
||
Export CSV/PDF — später Lohn-Schnittstelle (z. B. DATEV).
|
||
|
||
### MVP-Abgrenzung
|
||
**Drin:** Kommen/Gehen/Pause über Web/App/Kiosk (NFC/PIN), Stundenzettel,
|
||
revisionssichere Korrektur, CSV/PDF-Export. **Draußen (Folgeschritte):** Urlaub/
|
||
Krankmeldung, Schicht-/Dienstplanung, Projektzeiten, DATEV-Export, Geofencing.
|