Compare commits

..

10 Commits

Author SHA1 Message Date
79e996ab03 Deployment: Caddy-Edge (TLS + On-Demand für Custom-Domains) + Hetzner DNS
- Caddy ersetzt den Hetzner-LB: terminiert TLS (Portal-Domain automatisch) und
  load-balanced per reverse_proxy über die App-Nodes. Für Custom-Domains (§11)
  On-Demand-TLS, autorisiert über GET /internal/tls-allowed.
- TlsCheckController + DomainRepository::findVerifiedByHostname: erlaubt Zertifikate
  nur für Portal-Domain oder verifizierte Domains (Schutz vor Cert-Flooding).
- Terraform: hcloud_load_balancer entfernt, Caddy-Server + Firewall (80/443) +
  cloud-init-caddy (Caddyfile templated mit Upstreams/Domain/ACME).
- Optional Hetzner DNS via API (manage_dns): A-Record Portal + Wildcard → Caddy.
- nginx.prod: /internal zu Symfony geroutet; APP_PORTAL_DOMAIN-Env.

Validiert: Caddyfile (caddy validate), Terraform (validate), /internal/tls-allowed (200/403/400).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:13:29 +02:00
c3e05257cb Deployment: Hetzner Cloud via Terraform (Multi-Node, skalierbar)
Infrastruktur als Code für den Skalierungs-Test auf Hetzner:
- deploy/terraform: privates Netz, Firewalls, 2 App-Nodes, DB-Node, Load
  Balancer (Health-Check /health); cloud-init bootet Docker + Stack je Node
- deploy/compose/docker-compose.prod.yml + nginx.prod.conf: App-Node-Stack
  (PHP-FPM + Nginx) routet /api,/p,/t,/css,/health → Symfony, Rest → Vue-SPA
- App-Anpassungen: HealthController (/health für LB), brand.css nach /css
  verschoben (kein Pfad-Clash mit SPA-Assets im Prod-Routing)
- deploy/README.md: Anleitung inkl. JWT-Key-Verteilung & Cross-Node-Test
- reference.php (auto-generiert) aus Versionierung entfernt

Terraform validiert (terraform validate), Prod-Compose-Syntax geprüft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:20:58 +02:00
67e4353c8d Skalierbarkeit: Druck-Assets in S3-Object-Storage (Flysystem)
Macht die App-Nodes zustandslos (horizontal skalierbar): Hintergrund-PDFs
und Schriften liegen nicht mehr lokal, sondern im S3-kompatiblen Object
Storage (Flysystem + async-aws). In der DB stehen Storage-Keys.

- flysystem-bundle + async-aws (Storage "card_assets"), env-getrieben
  (S3_ENDPOINT/REGION/BUCKET/KEY/SECRET/PATH_STYLE) → lokal MinIO, prod Hetzner OS
- CardAssetUploadController: Upload/Read/Delete über Storage; GET streamt PDF
- CardPdfRenderer: liest Hintergrund (FPDI StreamReader) & Schriften (Temp-Datei) aus S3
- docker-compose: minio + minio-init (Bucket) + zweiter App-Node php2 (Profil scale-test)
- app:render-card Command für den Cross-Node-Nachweis

Verifiziert: Upload über Node 1 → identisches PDF-Render (51897 B, mit
Hintergrund) auf Node 2, der nur DB + Object Storage liest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:56:51 +02:00
73f05ed7e7 Karten-Editor: Format & Beschnitt einstellbar
Karten-Einstellungen im Eigenschaften-Panel (wenn kein Element gewählt):
Name, Breite/Höhe, Beschnitt und Sicherheitsabstand — wirken live im Canvas
und fließen beim Speichern ins Druck-PDF (Seitengröße + Schnittmarken).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:40:36 +02:00
904a4184fc Karten-Editor: Komfort — Hintergrund-Vorschau, Resize, Undo
- Hintergrund-PDF wird per pdf.js echt im Canvas gerendert (WYSIWYG);
  neuer Endpunkt GET .../card-template/background liefert das PDF
- Resize-Anfasser am ausgewählten Element (Breite/Höhe)
- Undo (↶ / Strg+Z) mit Snapshot-History; Snapshot erst bei echter Änderung

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:39:50 +02:00
f25ccefa48 Karten-Editor: Upload-UI für Hintergrund-PDF & Schriften
- Assets-Leiste: Hintergrund-PDF hochladen/entfernen (+ "aktiv"-Badge),
  Schrift hochladen (TTF/OTF) mit Familien-Chips
- Schriftart-Auswahl pro Text/Feld (Helvetica/Times/Courier + eigene)
- Canvas-Hinweis bei aktivem Hintergrund (echte Darstellung in PDF-Vorschau)
- Uploads aktualisieren State ohne Verlust des aktuellen Layouts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:33:52 +02:00
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
1a035d6c61 Visueller Visitenkarten-Editor (SPA)
- CardTemplateEditorController: GET|PUT /api/companies/{id}/card-template
  (gespeicherte oder Standardvorlage, Mandantenprüfung, Upsert)
- CardPdfRenderer: freie Hex-Farben unterstützt
- CardEditorView: Canvas im mm-Maßstab mit Beschnitt/Endformat/Sicherheit,
  Drag&Drop-Elemente (Feld/Text/QR/Logo/Fläche/Linie), Eigenschaften-Panel
  (Datenbindung, Position/Größe, Schrift, Ausrichtung, Farbe), Vorder-/Rück-
  seite, Live-Vorschau mit echten Daten, Speichern + PDF-Vorschau
- Nav-Eintrag "Visitenkarten" + Route
- Panel-Layout-Fix (min-width:0 gegen Grid-Überlauf)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:45:30 +02:00
408b37a5ea Druckdaten: druckfertige Visitenkarten-PDF (CMYK, Beschnitt, V/R)
- CardTemplate-Entität (Layout pro Firma; company=null = globale Vorlage)
- CardTemplateFactory: Standardlayout, greift Firmen-Branding + QR ab
- CardPdfRenderer (TCPDF): 85x55mm + 2mm Beschnitt, Schnittmarken, CMYK,
  Vorder-/Rückseite, mm-genaue Element-Platzierung, eingebetteter QR
- GET /api/employees/{id}/card.pdf (Auth + Mandantenprüfung)
- Konzept §13 (Druckdaten) ergänzt

Verifiziert: 2 Seiten, CMYK-Farbraum, Schnittmarken, Branding durchgängig.
Offen: PDF/X-1a-Finishing (Ghostscript), Font-Embedding, visueller Editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:54:02 +02:00
ebaf509a2f Fundament: Symfony+API-Platform-Backend & Vue-SPA (Phase 0–2)
Stack & Setup
- Dockerisierte Dev-Umgebung (PHP 8.4-FPM, Nginx, MariaDB 11.4)
- Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- Vue 3 + TS (Vite), Vue Router, Pinia, Axios

Kern-Domäne & Auth
- Entitäten: User, PlatformPlan, Reseller, Company, Domain, Location,
  Employee, ContactLink (UUIDv7)
- JWT-Login (/api/login), Rollen-Hierarchie, /api/me
- Mandantentrennung via API-Platform-Query-Extension (Lesen) +
  TenantStampProcessor (Schreiben)

Öffentliche Profile (SSR)
- Profil-Landingpage, vCard-Download, QR-Code im Marken-Look
- Stabiler NFC/QR-Kurz-Link /t/{code} -> Redirect aufs aktuelle Profil
- Firmenspezifisches Branding (Farben/Logo) auf der Profilseite

Verwaltungsoberfläche (SPA)
- Brand-Look (dunkle Sidebar), rollenbasierte Navigation
- Dashboard, Reseller (+Provisioning), Firmen, Mitarbeiter, Standorte,
  Domains, Design/Branding mit Live-Vorschau

Konzept & Doku: docs/KONZEPT.md (inkl. Wallet/Sync §12), README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:12:53 +02:00
140 changed files with 19641 additions and 2 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Abhängigkeiten
/backend/vendor/
/frontend/node_modules/
/frontend/dist/
# Symfony
/backend/var/
/backend/.env.local
/backend/.env.*.local
/backend/config/jwt/*.pem
# Umgebung
.env.local
*.log
# IDE/OS
.idea/
.vscode/
.DS_Store
# Playwright-Artefakte
/.playwright-mcp/
backend/.playwright-mcp/
# Auto-generierte Symfony-Config-Referenz
/backend/config/reference.php
# Terraform
deploy/terraform/.terraform/
deploy/terraform/*.tfstate
deploy/terraform/*.tfstate.*
deploy/terraform/terraform.tfvars
deploy/terraform/.terraform.lock.hcl

119
README.md Normal file
View File

@ -0,0 +1,119 @@
# vcard4reseller
White-Label-Plattform für digitale Visitenkarten (Reseller → Firmenkunde → Mitarbeiter).
Konzept & Datenmodell: siehe [`docs/KONZEPT.md`](docs/KONZEPT.md).
## Stack
- **Backend:** Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- **Frontend:** Vue 3 + TypeScript (Vite), Vue Router, Pinia, Axios
- **DB:** MariaDB 11.4
- **Dev-Umgebung:** Docker (PHP-FPM, Nginx, MariaDB)
## Verzeichnisse
```
backend/ Symfony-API (JSON/JSON-LD)
frontend/ Vue-3-SPA (Dashboards)
docker/ Dockerfile (PHP) + Nginx-Config
deploy/ Hetzner-Deployment (Terraform + Prod-Compose) — siehe deploy/README.md
docs/ Konzept & Datenmodell
```
## Schnellstart
Voraussetzung: Docker + Node 25.
```bash
# 1) Backend-Stack starten (PHP, Nginx, MariaDB)
export UID=$(id -u) GID=$(id -g)
docker compose up -d php nginx mariadb
# 2) JWT-Schlüssel erzeugen (einmalig)
docker compose exec php php bin/console lexik:jwt:generate-keypair --skip-if-exists
# 3) Frontend (Dev-Server mit API-Proxy auf :8080)
cd frontend && npm install && npm run dev
```
- API: http://localhost:8080/api
- Frontend: http://localhost:5173
- MariaDB: localhost:3306 (DB `vcard4reseller`, User `app` / `app`)
### Nützliche Befehle
```bash
# Symfony-Console im Container
docker compose exec php php bin/console <cmd>
# Migration erstellen / ausführen
docker compose exec php php bin/console doctrine:migrations:diff
docker compose exec php php bin/console doctrine:migrations:migrate
```
## Status
**Phase 0 (Setup) + Phase 1 (Kern-Domäne & Auth) abgeschlossen.**
Phase 1 umfasst: Entitäten (User, PlatformPlan, Reseller, Company, Domain,
Location, Employee, ContactLink), JWT-Login (`POST /api/login`),
Rollen-Hierarchie und automatische Mandantentrennung über eine
API-Platform-Query-Extension (`src/Doctrine/TenantExtension.php`).
Demo-Daten via `docker compose exec php php bin/console app:seed`:
| Rolle | E-Mail | Passwort |
|-------|--------|----------|
| Plattform-Admin | admin@vcard4reseller.de | admin |
| Reseller-Admin | reseller@demo.de | reseller |
| Firmen-Admin | firma@muster.de | firma |
**Phase 2 (öffentliche Profile) läuft.** Bereits umgesetzt: serverseitig
gerenderte Profilseite, vCard-Download und QR-Code im Marken-Look von
vcard4reseller.de (Design-Tokens in `backend/public/assets/brand.css`,
Referenz in `docs/design-reference/`).
Öffentliche Endpunkte (kein Login):
- `GET /p/{firma}/{mitarbeiter}` — Profil-Landingpage (Twig/SSR)
- `GET /p/{firma}/{mitarbeiter}/vcard.vcf` — vCard-Download
- `GET /p/{firma}/{mitarbeiter}/qr.png` — QR-Code (codiert die stabile Kurz-URL)
- `GET /t/{code}` — stabiler NFC/QR-Kurz-Link → Redirect aufs aktuelle Profil
Beispiel (nach `app:seed`): http://localhost:8080/p/muster/erika-mustermann
**Verwaltungsoberfläche (Vue-SPA) läuft.** Echtes Login gegen `/api/login`,
rollenbasierte App-Shell (dunkle Sidebar + Topbar im Brand-Look) und live an
die API gebundene Screens:
- **Dashboard** — Kennzahlen (rollenabhängig: Reseller/Firmen/Mitarbeiter/…)
- **Reseller** — Übersicht + Anlegen inkl. Admin-Zugang (nur Plattform-Admin)
- **Firmen** — Liste + Anlegen/Löschen (Reseller)
- **Mitarbeiter** — Tabelle, Suche, Anlegen/Bearbeiten/Löschen, Link zur öffentlichen Profilseite
- **Standorte**, **Domains** — Liste + Anlegen (Domains mit A-Record-Hinweis)
- **Design** — firmenspezifisches Branding (Primärfarbe/Logo) mit Live-Vorschau
- **Einstellungen** — Platzhalter
`/api/me` liefert der SPA Rollen + Mandantenkontext.
Start: `cd frontend && npm run dev` → http://localhost:5173 (Login z. B.
reseller@demo.de / reseller).
**Druckdaten (Kerngeschäft, in Arbeit):** druckfertige Visitenkarten als PDF
(CMYK, 85×55mm + 2mm Beschnitt + Schnittmarken, Vorder-/Rückseite) — Endpunkt
`GET /api/employees/{id}/card.pdf`. Layout via `CardTemplate` (Standardvorlage
greift Firmen-Branding + QR ab). Siehe `docs/KONZEPT.md` §13.
**Visueller Karten-Editor** (SPA, Menü „Visitenkarten"): Canvas im mm-Maßstab
mit Beschnitt/Endformat/Sicherheits-Markierung, Elemente per Drag&Drop
(Feld/Text/QR/Logo/Fläche/Linie), Eigenschaften-Panel (Position/Größe/Schrift/
Farbe/Datenbindung), Vorder-/Rückseite, Live-Vorschau mit echten Daten,
Speichern + PDF-Vorschau. Backend: `GET|PUT /api/companies/{id}/card-template`.
**Hintergrund-PDF (Variable Data Printing) + eigene Schriften:** Kunde kann
ein fertig gestaltetes Karten-PDF hochladen; die Plattform legt nur die
dynamischen Felder + QR darüber. Eigene Schriften (TTF/OTF) werden eingebettet.
Endpunkte: `POST /api/companies/{id}/card-template/background` und `.../font`.
Nächster Schritt: Editor-UI für Hintergrund-/Font-Upload, PDF/X-1a-Finishing
(Ghostscript), Sammel-PDF/Druckbogen, dann Wallet-Pässe (§12). Siehe `docs/KONZEPT.md` §9.

17
backend/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

69
backend/.env Normal file
View File

@ -0,0 +1,69 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a
###< lexik/jwt-authentication-bundle ###
###> App ###
# Portal-Domain (für On-Demand-TLS-Autorisierung). In Prod auf die echte Domain setzen.
APP_PORTAL_DOMAIN=localhost
###< App ###
###> S3 / Object Storage (Druck-Assets) ###
# Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen).
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=card-assets
S3_KEY=minioadmin
S3_SECRET=minioadmin
S3_PATH_STYLE=true
###< S3 / Object Storage ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

4
backend/.env.dev Normal file
View File

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=6a99dc78ab52a33deba7f8bd986720dc
###< symfony/framework-bundle ###

14
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###

21
backend/bin/console Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

View File

@ -0,0 +1,7 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

25
backend/compose.yaml Normal file
View File

@ -0,0 +1,25 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###

95
backend/composer.json Normal file
View File

@ -0,0 +1,95 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/doctrine-orm": "^4.3",
"api-platform/symfony": "^4.3",
"async-aws/async-aws-bundle": "^1.17",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"endroid/qr-code": "^6.1",
"league/flysystem-async-aws-s3": "^3.31",
"league/flysystem-bundle": "^3.7",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"setasign/fpdi": "^2.6",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.4.*",
"symfony/messenger": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*",
"tecnickcom/tcpdf": "^6.11"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
},
"require-dev": {
"symfony/maker-bundle": "^1.67"
}
}

9203
backend/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
AsyncAws\Symfony\Bundle\AsyncAwsBundle::class => ['all' => true],
];

View File

@ -0,0 +1,7 @@
api_platform:
title: Hello API Platform
version: 1.0.0
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']

View File

@ -0,0 +1,11 @@
async_aws:
clients:
card_assets:
type: s3
config:
region: '%env(S3_REGION)%'
endpoint: '%env(S3_ENDPOINT)%'
# Path-Style für MinIO (true). Hetzner Object Storage funktioniert auch mit true.
pathStyleEndpoint: '%env(bool:S3_PATH_STYLE)%'
accessKeyId: '%env(S3_KEY)%'
accessKeySecret: '%env(S3_SECRET)%'

View File

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@ -0,0 +1,46 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@ -0,0 +1,11 @@
# https://github.com/thephpleague/flysystem-bundle
flysystem:
storages:
# Druck-Assets (Hintergrund-PDFs, Schriften) im S3-kompatiblen Object Storage.
# Lokal: MinIO. Prod: Hetzner Object Storage. Gleicher Code, andere Env.
card_assets.storage:
adapter: 'asyncaws'
options:
client: 'async_aws.client.card_assets'
bucket: '%env(S3_BUCKET)%'
prefix: 'cards'

View File

@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@ -0,0 +1,4 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'

View File

@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View File

@ -0,0 +1,55 @@
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
# Öffentlicher Login-Endpunkt: tauscht E-Mail/Passwort gegen ein JWT
login:
pattern: ^/api/login$
stateless: true
json_login:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Geschützte API: JWT im Authorization-Header
api:
pattern: ^/api
stateless: true
provider: app_user_provider
jwt: ~
main:
lazy: true
provider: app_user_provider
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE]
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN]
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE]
when@test:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4
time_cost: 3
memory_cost: 10

View File

@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

View File

@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers

View File

@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View File

@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@ -0,0 +1,23 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

0
backend/migrations/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260530191712 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE company (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL, self_edit_enabled TINYINT NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) NOT NULL, INDEX IDX_4FBF094F91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE contact_link (id BINARY(16) NOT NULL, type VARCHAR(40) NOT NULL, url VARCHAR(500) NOT NULL, label VARCHAR(120) DEFAULT NULL, position INT NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_1E531B0E8C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE domain (id BINARY(16) NOT NULL, hostname VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, tls_status VARCHAR(20) NOT NULL, verification_checked_at DATETIME DEFAULT NULL, company_id BINARY(16) NOT NULL, UNIQUE INDEX UNIQ_A7A91E0BE551C011 (hostname), INDEX IDX_A7A91E0B979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE employee (id BINARY(16) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, slug VARCHAR(120) NOT NULL, title VARCHAR(150) DEFAULT NULL, position VARCHAR(150) DEFAULT NULL, department VARCHAR(150) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, mobile VARCHAR(50) DEFAULT NULL, photo_path VARCHAR(255) DEFAULT NULL, bio LONGTEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, self_edit_allowed TINYINT NOT NULL, editable_fields JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, location_id BINARY(16) DEFAULT NULL, INDEX IDX_5D9F75A1979B1AD6 (company_id), INDEX IDX_5D9F75A164D218E (location_id), UNIQUE INDEX uniq_employee_company_slug (company_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE location (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, street VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(20) DEFAULT NULL, city VARCHAR(120) DEFAULT NULL, country VARCHAR(2) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, branding_override JSON NOT NULL, company_id BINARY(16) NOT NULL, INDEX IDX_5E9E89CB979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE platform_plan (id BINARY(16) NOT NULL, name VARCHAR(100) NOT NULL, slug VARCHAR(100) NOT NULL, price_per_month INT NOT NULL, max_profiles INT NOT NULL, max_companies INT NOT NULL, features JSON NOT NULL, UNIQUE INDEX UNIQ_59523C51989D9B62 (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE reseller (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, primary_domain VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, platform_plan_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_18015899989D9B62 (slug), INDEX IDX_18015899FDA9C8C9 (platform_plan_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE `user` (id BINARY(16) NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, last_login_at DATETIME DEFAULT NULL, reseller_id BINARY(16) DEFAULT NULL, company_id BINARY(16) DEFAULT NULL, employee_id BINARY(16) DEFAULT NULL, INDEX IDX_8D93D64991E6A19D (reseller_id), INDEX IDX_8D93D649979B1AD6 (company_id), UNIQUE INDEX UNIQ_8D93D6498C03F15C (employee_id), UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE company ADD CONSTRAINT FK_4FBF094F91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE contact_link ADD CONSTRAINT FK_1E531B0E8C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
$this->addSql('ALTER TABLE domain ADD CONSTRAINT FK_A7A91E0B979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A1979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A164D218E FOREIGN KEY (location_id) REFERENCES location (id)');
$this->addSql('ALTER TABLE location ADD CONSTRAINT FK_5E9E89CB979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE reseller ADD CONSTRAINT FK_18015899FDA9C8C9 FOREIGN KEY (platform_plan_id) REFERENCES platform_plan (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D64991E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D649979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D6498C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE company DROP FOREIGN KEY FK_4FBF094F91E6A19D');
$this->addSql('ALTER TABLE contact_link DROP FOREIGN KEY FK_1E531B0E8C03F15C');
$this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B979B1AD6');
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A1979B1AD6');
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A164D218E');
$this->addSql('ALTER TABLE location DROP FOREIGN KEY FK_5E9E89CB979B1AD6');
$this->addSql('ALTER TABLE reseller DROP FOREIGN KEY FK_18015899FDA9C8C9');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D64991E6A19D');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D649979B1AD6');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D6498C03F15C');
$this->addSql('DROP TABLE company');
$this->addSql('DROP TABLE contact_link');
$this->addSql('DROP TABLE domain');
$this->addSql('DROP TABLE employee');
$this->addSql('DROP TABLE location');
$this->addSql('DROP TABLE platform_plan');
$this->addSql('DROP TABLE reseller');
$this->addSql('DROP TABLE `user`');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531085615 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee ADD short_code VARCHAR(16) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_shortcode ON employee (short_code)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_employee_shortcode ON employee');
$this->addSql('ALTER TABLE employee DROP short_code');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531092327 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6');
$this->addSql('DROP TABLE card_template');
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531150051 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// Hintergrund-PDF-Pfad + eigene Schriften. fonts wird nullable hinzugefügt und
// für bestehende Zeilen mit '[]' befüllt (sonst verletzt '' den JSON-CHECK), dann NOT NULL.
$this->addSql('ALTER TABLE card_template ADD background_path VARCHAR(255) DEFAULT NULL, ADD fonts JSON DEFAULT NULL');
$this->addSql("UPDATE card_template SET fonts = '[]' WHERE fonts IS NULL");
$this->addSql('ALTER TABLE card_template MODIFY fonts JSON NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP background_path, DROP fonts');
}
}

View File

@ -0,0 +1,82 @@
/* vcard4reseller — Marken-Design-System (orientiert an vcard4reseller.de) */
:root {
--psc-orange: #f58220;
--psc-orange-dark: #d96500;
--psc-orange-soft: #fff2e7;
--psc-orange-soft-2: #fff8f1;
--psc-border: #f4d4bb;
--text: #343434;
--dark: #222222;
--muted: #6f6f6f;
--bg: #f7f7f7;
--white: #ffffff;
--success: #238636;
--shadow: 0 18px 45px rgba(30, 30, 30, 0.10);
--shadow-sm: 0 6px 18px rgba(30, 30, 30, 0.08);
--radius: 22px;
--radius-sm: 14px;
--font: Arial, Helvetica, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: var(--bg);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { color: var(--dark); font-weight: 700; line-height: 1.2; margin: 0 0 .4em; }
a { color: var(--psc-orange-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Marken-Logo (Wortmarke) */
.brand-logo {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 1.15rem;
color: var(--dark);
letter-spacing: -0.01em;
}
.brand-logo .tag {
background: var(--psc-orange);
color: var(--white);
padding: 2px 10px;
border-radius: 999px;
margin-left: 4px;
}
/* Pill-Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .7rem 1.4rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: .95rem;
border: 1px solid transparent;
cursor: pointer;
transition: transform .08s ease, box-shadow .15s ease, background .15s ease;
}
.btn:hover { text-decoration: none; transform: translateY(-1px); }
.btn-primary { background: var(--psc-orange); color: var(--white); box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--psc-orange-dark); color: var(--white); }
.btn-soft { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.btn-soft:hover { background: var(--psc-border); color: var(--psc-orange-dark); }
.btn-ghost { background: var(--white); color: var(--dark); border-color: #e7e7e7; }
/* Karte */
.card {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid #f0f0f0;
}
.muted { color: var(--muted); }

9
backend/public/index.php Normal file
View File

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

0
backend/src/ApiResource/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,37 @@
<?php
namespace App\Command;
use App\Repository\EmployeeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:backfill-shortcodes', description: 'Vergibt fehlende NFC/QR-Kurz-Codes an bestehende Mitarbeiter.')]
final class BackfillShortCodesCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$missing = $this->employees->findWithoutShortCode();
foreach ($missing as $employee) {
$employee->ensureShortCode();
}
$this->em->flush();
$io->success(sprintf('%d Mitarbeiter mit Kurz-Code versehen.', count($missing)));
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Command;
use App\Repository\CardTemplateRepository;
use App\Repository\EmployeeRepository;
use App\Service\CardPdfRenderer;
use App\Service\CardTemplateFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Test-Utility: rendert die Visitenkarte eines Mitarbeiters und schreibt sie
* nach /tmp. Dient dem Skalierungs-Nachweis (auf mehreren Nodes ausführen
* jeder Node liest dieselbe DB + denselben Object Storage).
*/
#[AsCommand(name: 'app:render-card', description: 'Rendert eine Visitenkarte (Skalierungs-Test).')]
final class RenderCardCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly CardTemplateRepository $templates,
private readonly CardTemplateFactory $factory,
private readonly CardPdfRenderer $renderer,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('slug', InputArgument::REQUIRED, 'Employee-Slug');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$employee = $this->employees->findOneBy(['slug' => $input->getArgument('slug')]);
if (null === $employee) {
$output->writeln('<error>Mitarbeiter nicht gefunden.</error>');
return Command::FAILURE;
}
$template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default();
$pdf = $this->renderer->render($employee, $template);
$file = sprintf('/tmp/render-%s.pdf', gethostname());
file_put_contents($file, $pdf);
$output->writeln(sprintf(
'Node %s: %d bytes, Hintergrund=%s, Datei=%s',
gethostname(),
strlen($pdf),
$template->getBackgroundPath() ? 'ja' : 'nein',
$file,
));
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Command;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Employee;
use App\Entity\Location;
use App\Entity\PlatformPlan;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:seed', description: 'Legt Demo-Daten an (Admin, Reseller, Firmen, Mitarbeiter).')]
final class SeedCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $hasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (null !== $this->em->getRepository(User::class)->findOneBy(['email' => 'admin@vcard4reseller.de'])) {
$io->warning('Demo-Daten existieren bereits — übersprungen.');
return Command::SUCCESS;
}
// Plattform-Paket
$plan = (new PlatformPlan())
->setName('Professional')->setSlug('professional')
->setPricePerMonth(24900)->setMaxProfiles(500)->setMaxCompanies(8)
->setFeatures(['vcard', 'wallet', 'nfc', 'print']);
$this->em->persist($plan);
// Plattform-Admin
$admin = (new User())->setEmail('admin@vcard4reseller.de')->setRoles([User::ROLE_PLATFORM_ADMIN]);
$admin->setPassword($this->hasher->hashPassword($admin, 'admin'));
$this->em->persist($admin);
// Zwei Reseller mit je einer Firma (Beweis der Mandantentrennung)
$this->createReseller($plan, 'Demo Druckerei', 'demo', 'reseller@demo.de', 'Muster GmbH', 'muster', 'firma@muster.de', 'Erika', 'Mustermann');
$this->createReseller($plan, 'Print Studio', 'printstudio', 'reseller@printstudio.de', 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'Max', 'Beispiel');
$this->em->flush();
$io->success('Demo-Daten angelegt.');
$io->table(['Rolle', 'E-Mail', 'Passwort'], [
['Plattform-Admin', 'admin@vcard4reseller.de', 'admin'],
['Reseller-Admin', 'reseller@demo.de', 'reseller'],
['Reseller-Admin', 'reseller@printstudio.de', 'reseller'],
['Firmen-Admin', 'firma@muster.de', 'firma'],
['Firmen-Admin', 'firma@beispiel.de', 'firma'],
]);
return Command::SUCCESS;
}
private function createReseller(
PlatformPlan $plan,
string $resellerName,
string $resellerSlug,
string $resellerEmail,
string $companyName,
string $companySlug,
string $companyEmail,
string $firstName,
string $lastName,
): void {
$reseller = (new Reseller())
->setName($resellerName)->setSlug($resellerSlug)
->setPrimaryDomain($resellerSlug.'.vcard4reseller.de')
->setPlatformPlan($plan);
$this->em->persist($reseller);
$resellerAdmin = (new User())
->setEmail($resellerEmail)->setRoles([User::ROLE_RESELLER_ADMIN])->setReseller($reseller);
$resellerAdmin->setPassword($this->hasher->hashPassword($resellerAdmin, 'reseller'));
$this->em->persist($resellerAdmin);
$company = (new Company())
->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
$this->em->persist($company);
$companyAdmin = (new User())
->setEmail($companyEmail)->setRoles([User::ROLE_COMPANY_ADMIN])
->setReseller($reseller)->setCompany($company);
$companyAdmin->setPassword($this->hasher->hashPassword($companyAdmin, 'firma'));
$this->em->persist($companyAdmin);
$location = (new Location())
->setName('Hauptsitz')->setStreet('Musterstr. 1')->setPostalCode('10115')
->setCity('Berlin')->setCountry('DE')->setCompany($company);
$this->em->persist($location);
$employee = (new Employee())
->setFirstName($firstName)->setLastName($lastName)
->setSlug(strtolower($firstName.'-'.$lastName))
->setPosition('Geschäftsführung')->setEmail(strtolower($firstName).'@'.$companySlug.'.de')
->setPhone('+49 30 1234567')->setCompany($company)->setLocation($location)
->setSelfEditAllowed(true);
$this->em->persist($employee);
$link = (new ContactLink())
->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($firstName))
->setPosition(0);
$employee->addContactLink($link);
$this->em->persist($link);
}
}

0
backend/src/Controller/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,153 @@
<?php
namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Lädt Druck-Assets einer Firma in den (S3-)Object-Storage: Hintergrund-PDF
* (Variable Data Printing) und eigene Schriften. In der DB stehen Storage-Keys,
* keine lokalen Pfade dadurch nodeübergreifend lesbar (horizontal skalierbar).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardAssetUploadController
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_upload', methods: ['POST'])]
public function uploadBackground(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) {
throw new BadRequestHttpException('Nur PDF erlaubt.');
}
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'background', 'pdf');
$template->setBackgroundPath($key);
$this->em->persist($template);
$this->em->flush();
return new JsonResponse(['backgroundKey' => $key, 'fileName' => $file->getClientOriginalName()], 201);
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])]
public function getBackground(string $id): Response
{
$company = $this->company($id);
$key = $this->templates->findCardForCompany($company)?->getBackgroundPath();
if (!$key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Hintergrund-PDF.');
}
return new StreamedResponse(function () use ($key) {
fpassthru($this->cardAssets->readStream($key));
}, 200, ['Content-Type' => 'application/pdf']);
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])]
public function deleteBackground(string $id): JsonResponse
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
if ($template && $template->getBackgroundPath()) {
if ($this->cardAssets->fileExists($template->getBackgroundPath())) {
$this->cardAssets->delete($template->getBackgroundPath());
}
$template->setBackgroundPath(null);
$this->em->flush();
}
return new JsonResponse(['backgroundKey' => null]);
}
#[Route('/api/companies/{id}/card-template/font', name: 'card_font_upload', methods: ['POST'])]
public function uploadFont(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$file = $this->file($request);
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($ext, ['ttf', 'otf'], true)) {
throw new BadRequestHttpException('Nur TTF/OTF erlaubt.');
}
$family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'font', $ext);
$template->addFont($family, $key);
$this->em->flush();
return new JsonResponse(['fonts' => $template->getFonts()], 201);
}
private function file(Request $request): UploadedFile
{
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file") übermittelt.');
}
return $file;
}
/** Lädt die Datei in den Object-Storage und liefert den Key zurück. */
private function store(UploadedFile $file, Uuid $companyId, string $prefix, string $ext): string
{
$key = sprintf('%s/%s-%s.%s', $companyId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
return $key;
}
private function getOrCreate(Company $company): CardTemplate
{
return $this->templates->findCardForCompany($company)
?? (new CardTemplate())->setCompany($company);
}
private function company(string $id): Company
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $company;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $company;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Repository\CardTemplateRepository;
use App\Repository\EmployeeRepository;
use App\Security\TenantContext;
use App\Service\CardPdfRenderer;
use App\Service\CardTemplateFactory;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Erzeugt die druckfertige Visitenkarte (PDF) für einen Mitarbeiter.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardPdfController
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly CardTemplateRepository $templates,
private readonly CardTemplateFactory $factory,
private readonly CardPdfRenderer $renderer,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/{id}/card.pdf', name: 'employee_card_pdf', methods: ['GET'])]
public function __invoke(string $id): Response
{
$employee = $this->employees->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
$this->assertAccess($employee);
$template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default();
$pdf = $this->renderer->render($employee, $template);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('inline; filename="visitenkarte-%s.pdf"', $employee->getSlug()),
]);
}
private function assertAccess(Employee $employee): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $employee->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur Mitarbeiter der eigenen Firma.');
}
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use App\Service\CardTemplateFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Lädt/speichert die Visitenkarten-Vorlage einer Firma für den visuellen Editor.
* Gibt falls noch keine Vorlage existiert die Standardvorlage zurück.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardTemplateEditorController
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly CardTemplateFactory $factory,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$company = $this->company($id);
$template = $this->templates->findCardForCompany($company);
return new JsonResponse($this->serialize($template ?? $this->factory->default(), null === $template));
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_put', methods: ['PUT'])]
public function put(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$data = json_decode($request->getContent(), true) ?? [];
$template = $this->templates->findCardForCompany($company) ?? (new CardTemplate())->setCompany($company);
$template
->setName((string) ($data['name'] ?? 'Standard'))
->setWidthMm((float) ($data['widthMm'] ?? 85))
->setHeightMm((float) ($data['heightMm'] ?? 55))
->setBleedMm((float) ($data['bleedMm'] ?? 2))
->setSafeMm((float) ($data['safeMm'] ?? 4))
->setFront(is_array($data['front'] ?? null) ? $data['front'] : [])
->setBack(is_array($data['back'] ?? null) ? $data['back'] : []);
$this->em->persist($template);
$this->em->flush();
return new JsonResponse($this->serialize($template, false));
}
private function company(string $id): Company
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $company;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $company;
}
private function serialize(CardTemplate $t, bool $isDefault): array
{
return [
'id' => $isDefault ? null : (string) $t->getId(),
'isDefault' => $isDefault,
'name' => $t->getName(),
'widthMm' => $t->getWidthMm(),
'heightMm' => $t->getHeightMm(),
'bleedMm' => $t->getBleedMm(),
'safeMm' => $t->getSafeMm(),
'front' => $t->getFront(),
'back' => $t->getBack(),
'hasBackground' => null !== $t->getBackgroundPath(),
'fonts' => array_map(fn ($f) => $f['family'] ?? '', $t->getFonts()),
];
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Branding (Logo/Farben) einer Firma setzen. Erlaubt Firmen-Admins die
* Pflege ihres eigenen Brandings, ohne die Firma selbst ändern zu können.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CompanyBrandingController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/companies/{id}/branding', name: 'company_branding', methods: ['PATCH'])]
public function __invoke(string $id, Request $request): JsonResponse
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
$this->assertAccess($company);
$data = json_decode($request->getContent(), true) ?? [];
$company->setBrandingConfig($this->sanitize($data));
$this->em->flush();
return new JsonResponse($company->getBrandingConfig());
}
private function assertAccess(Company $company): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma darf bearbeitet werden.');
}
}
/** Nur erlaubte, validierte Felder übernehmen (verhindert CSS-Injection). */
private function sanitize(array $data): array
{
$out = [];
foreach (['primaryColor', 'primaryDark'] as $key) {
$val = (string) ($data[$key] ?? '');
if (preg_match('/^#[0-9a-fA-F]{6}$/', $val)) {
$out[$key] = $val;
}
}
$logo = (string) ($data['logoUrl'] ?? '');
if (str_starts_with($logo, 'https://') || str_starts_with($logo, '/')) {
$out['logoUrl'] = $logo;
}
return $out;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Health-Check für den Load Balancer. Öffentlich (nicht unter /api),
* liefert 200, solange App + DB erreichbar sind, sonst 503.
*/
final class HealthController
{
#[Route('/health', name: 'health', methods: ['GET'])]
public function __invoke(Connection $connection): JsonResponse
{
try {
$connection->executeQuery('SELECT 1');
$db = 'up';
$status = 200;
} catch (\Throwable) {
$db = 'down';
$status = 503;
}
return new JsonResponse(['status' => 200 === $status ? 'ok' : 'degraded', 'db' => $db], $status);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Liefert Infos zum eingeloggten Nutzer für die SPA (Rollen + Mandantenkontext).
*/
final class MeController
{
public function __construct(private readonly Security $security)
{
}
#[Route('/api/me', name: 'api_me', methods: ['GET'])]
public function __invoke(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Not authenticated'], 401);
}
$reseller = $user->getReseller();
$company = $user->getCompany();
return new JsonResponse([
'id' => (string) $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'reseller' => $reseller ? ['id' => (string) $reseller->getId(), 'name' => $reseller->getName()] : null,
'company' => $company ? ['id' => (string) $company->getId(), 'name' => $company->getName()] : null,
]);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Service\VCardBuilder;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Öffentliche, serverseitig gerenderte Profilseiten (SSR, SEO).
* Nicht mandantengefiltert und ohne Auth bewusst öffentlich.
*/
final class PublicProfileController extends AbstractController
{
public function __construct(private readonly EmployeeRepository $employees)
{
}
#[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])]
public function show(string $companySlug, string $slug): Response
{
$employee = $this->resolve($companySlug, $slug);
return $this->render('public/profile.html.twig', [
'e' => $employee,
'profileUrl' => $this->profileUrl($employee),
'shareUrl' => $this->shareUrl($employee),
]);
}
#[Route('/p/{companySlug}/{slug}/vcard.vcf', name: 'public_profile_vcard', methods: ['GET'])]
public function vcard(string $companySlug, string $slug, VCardBuilder $builder): Response
{
$employee = $this->resolve($companySlug, $slug);
return new Response($builder->build($employee), 200, [
'Content-Type' => 'text/vcard; charset=utf-8',
'Content-Disposition' => sprintf('attachment; filename="%s.vcf"', $employee->getSlug()),
]);
}
#[Route('/p/{companySlug}/{slug}/qr.png', name: 'public_profile_qr', methods: ['GET'])]
public function qr(string $companySlug, string $slug): Response
{
$employee = $this->resolve($companySlug, $slug);
$result = (new Builder(
writer: new PngWriter(),
data: $this->shareUrl($employee),
encoding: new Encoding('UTF-8'),
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 320,
margin: 12,
))->build();
return new Response($result->getString(), 200, ['Content-Type' => $result->getMimeType()]);
}
private function resolve(string $companySlug, string $slug): Employee
{
$employee = $this->employees->findPublic($companySlug, $slug);
if (null === $employee) {
throw $this->createNotFoundException('Profil nicht gefunden.');
}
return $employee;
}
private function profileUrl(Employee $employee): string
{
return $this->generateUrl('public_profile', [
'companySlug' => $employee->getCompany()->getSlug(),
'slug' => $employee->getSlug(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
/**
* Stabile Teilen-URL: bevorzugt den Kurz-Code (/t/{code}, NFC/QR-tauglich),
* fällt sonst auf die Profil-URL zurück.
*/
private function shareUrl(Employee $employee): string
{
if (null !== $employee->getShortCode()) {
return $this->generateUrl('short_link', ['code' => $employee->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL);
}
return $this->profileUrl($employee);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Controller;
use App\Entity\PlatformPlan;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Legt einen Reseller an optional zusammen mit seinem Admin-Benutzer,
* damit sich der Reseller direkt einloggen kann. Nur für Plattform-Admins.
*/
#[IsGranted('ROLE_PLATFORM_ADMIN')]
final class ResellerProvisioningController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $hasher,
) {
}
#[Route('/api/platform/provision-reseller', name: 'platform_provision_reseller', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$d = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($d['name'] ?? ''));
$slug = trim((string) ($d['slug'] ?? ''));
if ('' === $name || '' === $slug) {
return new JsonResponse(['error' => 'name und slug sind erforderlich.'], 422);
}
$reseller = (new Reseller())->setName($name)->setSlug($slug);
if (!empty($d['primaryDomain'])) {
$reseller->setPrimaryDomain((string) $d['primaryDomain']);
}
if (!empty($d['planId'])) {
$plan = $this->em->getRepository(PlatformPlan::class)->find($d['planId']);
if ($plan instanceof PlatformPlan) {
$reseller->setPlatformPlan($plan);
}
}
$adminEmail = trim((string) ($d['adminEmail'] ?? ''));
$adminPassword = (string) ($d['adminPassword'] ?? '');
$admin = null;
if ('' !== $adminEmail && '' !== $adminPassword) {
$admin = (new User())
->setEmail($adminEmail)
->setRoles([User::ROLE_RESELLER_ADMIN])
->setReseller($reseller);
$admin->setPassword($this->hasher->hashPassword($admin, $adminPassword));
}
try {
$this->em->persist($reseller);
if ($admin) {
$this->em->persist($admin);
}
$this->em->flush();
} catch (UniqueConstraintViolationException) {
return new JsonResponse(['error' => 'Slug oder Admin-E-Mail bereits vergeben.'], 422);
}
return new JsonResponse([
'id' => (string) $reseller->getId(),
'name' => $reseller->getName(),
'slug' => $reseller->getSlug(),
'adminCreated' => null !== $admin,
], 201);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class SecurityController
{
/**
* Login-Endpunkt. Die eigentliche Authentifizierung erledigt der
* json_login-Authenticator (LexikJWT) bereits vor dem Controller
* diese Methode wird im Erfolgsfall nie erreicht.
*/
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(): JsonResponse
{
return new JsonResponse(['error' => 'Authentication failed'], 401);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use App\Repository\EmployeeRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Stabiler Kurz-Link für NFC-Tags / gedruckte QR-Codes.
* Leitet anhand des unveränderlichen Codes immer auf das aktuelle Profil um.
*/
final class ShortLinkController extends AbstractController
{
public function __construct(private readonly EmployeeRepository $employees)
{
}
#[Route('/t/{code}', name: 'short_link', methods: ['GET'])]
public function __invoke(string $code): Response
{
$employee = $this->employees->findByShortCode($code);
if (null === $employee) {
throw $this->createNotFoundException('Unbekannter Code.');
}
return new RedirectResponse($this->generateUrl('public_profile', [
'companySlug' => $employee->getCompany()->getSlug(),
'slug' => $employee->getSlug(),
]), 302);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Controller;
use App\Repository\DomainRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* On-Demand-TLS-Autorisierung für Caddy (KONZEPT §11): Caddy fragt vor dem
* Ausstellen eines Let's-Encrypt-Zertifikats hier nach, ob die Domain erlaubt ist.
* Erlaubt = Portal-Domain oder eine verifizierte Custom-Domain aus der DB.
* 200 ausstellen, sonst ablehnen (verhindert unbegrenzte Zertifikatsanfragen).
*/
final class TlsCheckController
{
public function __construct(
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
) {
}
#[Route('/internal/tls-allowed', name: 'tls_allowed', methods: ['GET'])]
public function __invoke(Request $request, DomainRepository $domains): Response
{
$host = strtolower(trim((string) $request->query->get('domain')));
if ('' === $host) {
return new Response('missing domain', 400);
}
$portal = strtolower($this->portalDomain);
if ($host === $portal || $host === 'www.'.$portal) {
return new Response('ok', 200);
}
if (null !== $domains->findVerifiedByHostname($host)) {
return new Response('ok', 200);
}
return new Response('not allowed', 403);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Security\TenantContext;
use Doctrine\ORM\QueryBuilder;
/**
* Schränkt jede API-Query automatisch auf den Mandanten des eingeloggten
* Nutzers ein (Reseller-, ggf. zusätzlich Company-Ebene).
* Plattform-Admins werden nicht eingeschränkt.
*/
final class TenantExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private readonly TenantContext $tenant)
{
}
public function applyToCollection(
QueryBuilder $qb,
QueryNameGeneratorInterface $nameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->apply($qb, $resourceClass);
}
/** @param array<string, mixed> $identifiers */
public function applyToItem(
QueryBuilder $qb,
QueryNameGeneratorInterface $nameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->apply($qb, $resourceClass);
}
private function apply(QueryBuilder $qb, string $resourceClass): void
{
// Plattform-Admins sehen alles
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller) {
// Kein Mandantenkontext → nichts ausliefern (statt Datenleck)
$qb->andWhere('1 = 0');
return;
}
$alias = $qb->getRootAliases()[0];
// Join-Pfad zur Reseller-/Company-Spalte je nach Entität
[$companyAlias, $resellerExpr] = match ($resourceClass) {
Company::class => [$alias, "$alias.reseller"],
Location::class, Domain::class => [
$this->joinOnce($qb, "$alias.company", 'tc'),
'tc.reseller',
],
Employee::class => [
$this->joinOnce($qb, "$alias.company", 'tc'),
'tc.reseller',
],
ContactLink::class => (function () use ($qb, $alias) {
$this->joinOnce($qb, "$alias.employee", 'te');
$c = $this->joinOnce($qb, 'te.company', 'tc');
return [$c, 'tc.reseller'];
})(),
default => [null, null],
};
if (null === $resellerExpr) {
return; // nicht mandantengebunden (z. B. PlatformPlan)
}
$qb->andWhere("$resellerExpr = :tenant_reseller")
->setParameter('tenant_reseller', $reseller->getId(), 'uuid');
// Firmen-Admins/Mitarbeiter zusätzlich auf ihre Company einschränken
$company = $this->tenant->getCompany();
if (null !== $company) {
if (Company::class === $resourceClass) {
$qb->andWhere("$alias = :tenant_company");
} else {
$qb->andWhere("$companyAlias = :tenant_company");
}
$qb->setParameter('tenant_company', $company->getId(), 'uuid');
}
}
/** Fügt einen Join nur hinzu, falls der Alias noch nicht existiert. */
private function joinOnce(QueryBuilder $qb, string $path, string $alias): string
{
foreach ($qb->getDQLPart('join') as $joins) {
foreach ($joins as $join) {
if ($join->getAlias() === $alias) {
return $alias;
}
}
}
$qb->join($path, $alias);
return $alias;
}
}

0
backend/src/Entity/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,232 @@
<?php
namespace App\Entity;
use App\Repository\CardTemplateRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Layout-Vorlage für druckbare Ausgaben (Visitenkarte; später Briefpapier).
* company = null globale Standardvorlage. Front/Back sind Listen von
* Element-Definitionen (siehe KONZEPT §13), die der CardPdfRenderer interpretiert.
*/
#[ORM\Entity(repositoryClass: CardTemplateRepository::class)]
class CardTemplate implements ResellerOwnedInterface
{
public const TYPE_CARD = 'card';
public const TYPE_LETTERHEAD = 'letterhead';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 120)]
private string $name = 'Standard';
#[ORM\Column(length: 20)]
private string $type = self::TYPE_CARD;
#[ORM\Column(type: 'float')]
private float $widthMm = 85.0;
#[ORM\Column(type: 'float')]
private float $heightMm = 55.0;
#[ORM\Column(type: 'float')]
private float $bleedMm = 2.0;
#[ORM\Column(type: 'float')]
private float $safeMm = 4.0;
/** @var array<int, array<string, mixed>> */
#[ORM\Column(type: 'json')]
private array $front = [];
/** @var array<int, array<string, mixed>> */
#[ORM\Column(type: 'json')]
private array $back = [];
/** Pfad zum hochgeladenen Hintergrund-PDF des Kunden (Seite 1 = Vorder-, 2 = Rückseite). */
#[ORM\Column(length: 255, nullable: true)]
private ?string $backgroundPath = null;
/** @var array<int, array{family: string, path: string}> Eingebettete eigene Schriften */
#[ORM\Column(type: 'json')]
private array $fonts = [];
#[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getWidthMm(): float
{
return $this->widthMm;
}
public function setWidthMm(float $widthMm): self
{
$this->widthMm = $widthMm;
return $this;
}
public function getHeightMm(): float
{
return $this->heightMm;
}
public function setHeightMm(float $heightMm): self
{
$this->heightMm = $heightMm;
return $this;
}
public function getBleedMm(): float
{
return $this->bleedMm;
}
public function setBleedMm(float $bleedMm): self
{
$this->bleedMm = $bleedMm;
return $this;
}
public function getSafeMm(): float
{
return $this->safeMm;
}
public function setSafeMm(float $safeMm): self
{
$this->safeMm = $safeMm;
return $this;
}
/** @return array<int, array<string, mixed>> */
public function getFront(): array
{
return $this->front;
}
/** @param array<int, array<string, mixed>> $front */
public function setFront(array $front): self
{
$this->front = $front;
return $this;
}
/** @return array<int, array<string, mixed>> */
public function getBack(): array
{
return $this->back;
}
/** @param array<int, array<string, mixed>> $back */
public function setBack(array $back): self
{
$this->back = $back;
return $this;
}
public function getBackgroundPath(): ?string
{
return $this->backgroundPath;
}
public function setBackgroundPath(?string $backgroundPath): self
{
$this->backgroundPath = $backgroundPath;
return $this;
}
/** @return array<int, array{family: string, path: string}> */
public function getFonts(): array
{
return $this->fonts;
}
/** @param array<int, array{family: string, path: string}> $fonts */
public function setFonts(array $fonts): self
{
$this->fonts = $fonts;
return $this;
}
public function addFont(string $family, string $path): self
{
// bestehende gleicher Familie ersetzen
$this->fonts = array_values(array_filter($this->fonts, fn ($f) => ($f['family'] ?? null) !== $family));
$this->fonts[] = ['family' => $family, 'path' => $path];
return $this;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): self
{
$this->company = $company;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company?->getReseller();
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -0,0 +1,182 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\CompanyRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Firmenkunde eines Resellers.
*/
#[ORM\Entity(repositoryClass: CompanyRepository::class)]
#[ApiResource(
operations: [
// Lesen: auch Firmen-Admins (Tenant-Extension beschränkt auf die eigene Firma)
new GetCollection(security: "is_granted('ROLE_COMPANY_ADMIN')"),
new Get(security: "is_granted('ROLE_COMPANY_ADMIN')"),
// Schreiben: nur Reseller-Admins (und Plattform-Admins via Hierarchie)
new Post(security: "is_granted('ROLE_RESELLER_ADMIN')"),
new Patch(security: "is_granted('ROLE_RESELLER_ADMIN')"),
new Delete(security: "is_granted('ROLE_RESELLER_ADMIN')"),
],
)]
class Company implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 100)]
private string $slug;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Erlaubt Mitarbeiter-Self-Service generell (zusätzlich pro Employee freizugeben). */
#[ORM\Column]
private bool $selfEditEnabled = false;
#[ORM\Column(type: 'json')]
private array $brandingConfig = [];
#[ORM\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')]
#[ORM\JoinColumn(nullable: false)]
private Reseller $reseller;
/** @var Collection<int, Location> */
#[ORM\OneToMany(targetEntity: Location::class, mappedBy: 'company')]
private Collection $locations;
/** @var Collection<int, Employee> */
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'company')]
private Collection $employees;
/** @var Collection<int, Domain> */
#[ORM\OneToMany(targetEntity: Domain::class, mappedBy: 'company')]
private Collection $domains;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->locations = new ArrayCollection();
$this->employees = new ArrayCollection();
$this->domains = new ArrayCollection();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function isSelfEditEnabled(): bool
{
return $this->selfEditEnabled;
}
public function setSelfEditEnabled(bool $selfEditEnabled): self
{
$this->selfEditEnabled = $selfEditEnabled;
return $this;
}
public function getBrandingConfig(): array
{
return $this->brandingConfig;
}
public function setBrandingConfig(array $brandingConfig): self
{
$this->brandingConfig = $brandingConfig;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
/** @return Collection<int, Location> */
public function getLocations(): Collection
{
return $this->locations;
}
/** @return Collection<int, Employee> */
public function getEmployees(): Collection
{
return $this->employees;
}
/** @return Collection<int, Domain> */
public function getDomains(): Collection
{
return $this->domains;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\ContactLinkRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Social-/Web-Link auf einem Mitarbeiterprofil.
*/
#[ORM\Entity(repositoryClass: ContactLinkRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class ContactLink implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
/** z. B. website, linkedin, xing, instagram, email, phone. */
#[ORM\Column(length: 40)]
private string $type;
#[ORM\Column(length: 500)]
private string $url;
#[ORM\Column(length: 120, nullable: true)]
private ?string $label = null;
#[ORM\Column]
private int $position = 0;
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contactLinks')]
#[ORM\JoinColumn(nullable: false)]
private Employee $employee;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getUrl(): string
{
return $this->url;
}
public function setUrl(string $url): self
{
$this->url = $url;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): self
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): self
{
$this->position = $position;
return $this;
}
public function getEmployee(): Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->employee->getReseller();
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\DomainRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Sub- oder Custom-Domain eines Firmenkunden (siehe KONZEPT §11).
*/
#[ORM\Entity(repositoryClass: DomainRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Domain implements ResellerOwnedInterface
{
public const TYPE_SUBDOMAIN = 'subdomain';
public const TYPE_CUSTOM = 'custom';
public const STATUS_PENDING = 'pending';
public const STATUS_VERIFIED = 'verified';
public const STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 255, unique: true)]
private string $hostname;
#[ORM\Column(length: 20)]
private string $type = self::TYPE_SUBDOMAIN;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(length: 20)]
private string $tlsStatus = 'none';
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $verificationCheckedAt = null;
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'domains')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getHostname(): string
{
return $this->hostname;
}
public function setHostname(string $hostname): self
{
$this->hostname = $hostname;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getTlsStatus(): string
{
return $this->tlsStatus;
}
public function setTlsStatus(string $tlsStatus): self
{
$this->tlsStatus = $tlsStatus;
return $this;
}
public function getVerificationCheckedAt(): ?\DateTimeImmutable
{
return $this->verificationCheckedAt;
}
public function setVerificationCheckedAt(?\DateTimeImmutable $verificationCheckedAt): self
{
$this->verificationCheckedAt = $verificationCheckedAt;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
}

View File

@ -0,0 +1,374 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\EmployeeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle.
*/
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Employee implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 100)]
private string $firstName;
#[ORM\Column(length: 100)]
private string $lastName;
/** Öffentlicher Slug (eindeutig innerhalb der Firma). */
#[ORM\Column(length: 120)]
private string $slug;
/**
* Stabiler Kurz-Code für NFC-Tags / gedruckte QR-Codes. Verweist per
* Redirect (/t/{code}) immer auf das aktuelle Profil bleibt also gleich,
* auch wenn sich der Slug ändert. Generiert beim Anlegen, nicht editierbar.
*/
#[ORM\Column(length: 16, nullable: true)]
private ?string $shortCode = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $position = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $department = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $mobile = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $photoPath = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $bio = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Pro-Mitarbeiter-Freigabe für Self-Service (zusätzlich zu Company::selfEditEnabled). */
#[ORM\Column]
private bool $selfEditAllowed = false;
/** @var string[] Felder, die der Mitarbeiter selbst ändern darf (leer = alle erlaubten). */
#[ORM\Column(type: 'json')]
private array $editableFields = [];
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'employees')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
#[ORM\ManyToOne(targetEntity: Location::class)]
private ?Location $location = null;
#[ORM\OneToOne(targetEntity: User::class, mappedBy: 'employee')]
private ?User $user = null;
/** @var Collection<int, ContactLink> */
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $contactLinks;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->shortCode = bin2hex(random_bytes(4));
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
$this->contactLinks = new ArrayCollection();
}
public function getShortCode(): ?string
{
return $this->shortCode;
}
public function ensureShortCode(): void
{
if (null === $this->shortCode) {
$this->shortCode = bin2hex(random_bytes(4));
}
}
public function getId(): Uuid
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
public function getPosition(): ?string
{
return $this->position;
}
public function setPosition(?string $position): self
{
$this->position = $position;
return $this;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): self
{
$this->department = $department;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): self
{
$this->mobile = $mobile;
return $this;
}
public function getPhotoPath(): ?string
{
return $this->photoPath;
}
public function setPhotoPath(?string $photoPath): self
{
$this->photoPath = $photoPath;
return $this;
}
public function getBio(): ?string
{
return $this->bio;
}
public function setBio(?string $bio): self
{
$this->bio = $bio;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function isSelfEditAllowed(): bool
{
return $this->selfEditAllowed;
}
public function setSelfEditAllowed(bool $selfEditAllowed): self
{
$this->selfEditAllowed = $selfEditAllowed;
return $this;
}
/** @return string[] */
public function getEditableFields(): array
{
return $this->editableFields;
}
/** @param string[] $editableFields */
public function setEditableFields(array $editableFields): self
{
$this->editableFields = $editableFields;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
/** @return Collection<int, ContactLink> */
public function getContactLinks(): Collection
{
return $this->contactLinks;
}
public function addContactLink(ContactLink $link): self
{
if (!$this->contactLinks->contains($link)) {
$this->contactLinks->add($link);
$link->setEmployee($this);
}
return $this;
}
public function removeContactLink(ContactLink $link): self
{
$this->contactLinks->removeElement($link);
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\LocationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Standort eines Firmenkunden.
*/
#[ORM\Entity(repositoryClass: LocationRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Location implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $postalCode = null;
#[ORM\Column(length: 120, nullable: true)]
private ?string $city = null;
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $email = null;
/** Standort-spezifisches Branding-Override. */
#[ORM\Column(type: 'json')]
private array $brandingOverride = [];
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'locations')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): self
{
$this->street = $street;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): self
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): self
{
$this->city = $city;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): self
{
$this->country = $country;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getBrandingOverride(): array
{
return $this->brandingOverride;
}
public function setBrandingOverride(array $brandingOverride): self
{
$this->brandingOverride = $brandingOverride;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Get;
use App\Repository\PlatformPlanRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Reseller-Paket der Plattform (Starter/Professional/Business).
* Definiert Preis und Limits, die gegenüber dem Reseller durchgesetzt werden.
*/
#[ORM\Entity(repositoryClass: PlatformPlanRepository::class)]
#[ApiResource(
operations: [new GetCollection(), new Get()],
security: "is_granted('ROLE_RESELLER_ADMIN')",
)]
class PlatformPlan
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 100)]
private string $name;
#[ORM\Column(length: 100, unique: true)]
private string $slug;
/** Monatspreis in Cent. */
#[ORM\Column]
private int $pricePerMonth = 0;
#[ORM\Column]
private int $maxProfiles = 0;
#[ORM\Column]
private int $maxCompanies = 0;
/** @var string[] */
#[ORM\Column]
private array $features = [];
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getPricePerMonth(): int
{
return $this->pricePerMonth;
}
public function setPricePerMonth(int $pricePerMonth): self
{
$this->pricePerMonth = $pricePerMonth;
return $this;
}
public function getMaxProfiles(): int
{
return $this->maxProfiles;
}
public function setMaxProfiles(int $maxProfiles): self
{
$this->maxProfiles = $maxProfiles;
return $this;
}
public function getMaxCompanies(): int
{
return $this->maxCompanies;
}
public function setMaxCompanies(int $maxCompanies): self
{
$this->maxCompanies = $maxCompanies;
return $this;
}
/** @return string[] */
public function getFeatures(): array
{
return $this->features;
}
/** @param string[] $features */
public function setFeatures(array $features): self
{
$this->features = $features;
return $this;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\ResellerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Oberster Mandant: Druckerei/Agentur, die Firmenkunden verwaltet.
*/
#[ORM\Entity(repositoryClass: ResellerRepository::class)]
#[ApiResource(security: "is_granted('ROLE_PLATFORM_ADMIN')")]
class Reseller
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 100, unique: true)]
private string $slug;
/** Haupt-Domain des Resellers (für Subdomains der Firmenkunden). */
#[ORM\Column(length: 255, nullable: true)]
private ?string $primaryDomain = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Branding-Defaults (Logo, Farben, Fonts). */
#[ORM\Column(type: 'json')]
private array $brandingConfig = [];
#[ORM\ManyToOne(targetEntity: PlatformPlan::class)]
private ?PlatformPlan $platformPlan = null;
/** @var Collection<int, Company> */
#[ORM\OneToMany(targetEntity: Company::class, mappedBy: 'reseller')]
private Collection $companies;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->companies = new ArrayCollection();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getPrimaryDomain(): ?string
{
return $this->primaryDomain;
}
public function setPrimaryDomain(?string $primaryDomain): self
{
$this->primaryDomain = $primaryDomain;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getBrandingConfig(): array
{
return $this->brandingConfig;
}
public function setBrandingConfig(array $brandingConfig): self
{
$this->brandingConfig = $brandingConfig;
return $this;
}
public function getPlatformPlan(): ?PlatformPlan
{
return $this->platformPlan;
}
public function setPlatformPlan(?PlatformPlan $platformPlan): self
{
$this->platformPlan = $platformPlan;
return $this;
}
/** @return Collection<int, Company> */
public function getCompanies(): Collection
{
return $this->companies;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Entity;
/**
* Markiert Entitäten, die zu genau einem Reseller gehören.
* Der Doctrine-Mandantenfilter schränkt Queries automatisch auf den
* Reseller des eingeloggten Nutzers ein (siehe App\Doctrine\ResellerFilter).
*/
interface ResellerOwnedInterface
{
public function getReseller(): ?Reseller;
}

183
backend/src/Entity/User.php Normal file
View File

@ -0,0 +1,183 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'uniq_user_email', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_PLATFORM_ADMIN = 'ROLE_PLATFORM_ADMIN';
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 180)]
private string $email;
/** @var string[] */
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private string $password;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Reseller-Kontext (für Reseller-Admins). */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
private ?Reseller $reseller = null;
/** Firmen-Kontext (für Firmen-Admins). */
#[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null;
/** Verknüpftes Mitarbeiterprofil (für Self-Service). */
#[ORM\OneToOne(targetEntity: Employee::class, inversedBy: 'user')]
private ?Employee $employee = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt = null;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return $this->email;
}
/** @return string[] */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param string[] $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(?Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): self
{
$this->company = $company;
return $this;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getLastLoginAt(): ?\DateTimeImmutable
{
return $this->lastLoginAt;
}
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): self
{
$this->lastLoginAt = $lastLoginAt;
return $this;
}
public function eraseCredentials(): void
{
// ggf. temporäre, sensible Daten löschen
}
}

11
backend/src/Kernel.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
backend/src/Repository/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,25 @@
<?php
namespace App\Repository;
use App\Entity\CardTemplate;
use App\Entity\Company;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CardTemplate>
*/
class CardTemplateRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CardTemplate::class);
}
/** Vorlage einer Firma (Karten-Typ), falls vorhanden. */
public function findCardForCompany(Company $company): ?CardTemplate
{
return $this->findOneBy(['company' => $company, 'type' => CardTemplate::TYPE_CARD]);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Company;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Company>
*/
class CompanyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Company::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\ContactLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContactLink>
*/
class ContactLinkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactLink::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Repository;
use App\Entity\Domain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Domain>
*/
class DomainRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Domain::class);
}
/** Verifizierte (TLS-fähige) Domain anhand des Hostnamens. */
public function findVerifiedByHostname(string $hostname): ?Domain
{
return $this->findOneBy(['hostname' => $hostname, 'status' => Domain::STATUS_VERIFIED]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Repository;
use App\Entity\Employee;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Employee>
*/
class EmployeeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Employee::class);
}
/**
* Lädt ein öffentlich sichtbares (aktives) Profil anhand Firmen- und
* Mitarbeiter-Slug. Nicht mandantengefiltert diese Seiten sind öffentlich.
*/
public function findPublic(string $companySlug, string $slug): ?Employee
{
return $this->createQueryBuilder('e')
->join('e.company', 'c')
->andWhere('c.slug = :companySlug')
->andWhere('e.slug = :slug')
->andWhere('e.status = :status')
->setParameter('companySlug', $companySlug)
->setParameter('slug', $slug)
->setParameter('status', 'active')
->getQuery()
->getOneOrNullResult();
}
/** Aktives Profil über den stabilen NFC/QR-Kurz-Code (für /t/{code}). */
public function findByShortCode(string $shortCode): ?Employee
{
return $this->findOneBy(['shortCode' => $shortCode, 'status' => 'active']);
}
/** @return Employee[] Mitarbeiter ohne Kurz-Code (für Backfill). */
public function findWithoutShortCode(): array
{
return $this->createQueryBuilder('e')
->andWhere('e.shortCode IS NULL')
->getQuery()
->getResult();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Location;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Location>
*/
class LocationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Location::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\PlatformPlan;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlatformPlan>
*/
class PlatformPlanRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlatformPlan::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Reseller;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Reseller>
*/
class ResellerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Reseller::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Security;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Nutzers.
* Plattform-Admins haben keinen Mandantenkontext und sehen alles.
*/
final class TenantContext
{
public function __construct(private readonly Security $security)
{
}
public function isPlatformAdmin(): bool
{
return $this->security->isGranted(User::ROLE_PLATFORM_ADMIN);
}
public function getReseller(): ?Reseller
{
$user = $this->security->getUser();
return $user instanceof User ? $user->getReseller() : null;
}
public function getCompany(): ?Company
{
$user = $this->security->getUser();
return $user instanceof User ? $user->getCompany() : null;
}
}

View File

@ -0,0 +1,281 @@
<?php
namespace App\Service;
use App\Entity\CardTemplate;
use App\Entity\Employee;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use League\Flysystem\FilesystemOperator;
use setasign\Fpdi\PdfParser\StreamReader;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Rendert eine druckfertige Visitenkarte (CMYK, Beschnitt + Schnittmarken,
* Vorder-/Rückseite) aus einer CardTemplate + Mitarbeiterprofil via TCPDF.
* Koordinaten der Vorlage sind in mm im Trim-Raum (0,0 = Endformat-Ecke).
*/
final class CardPdfRenderer
{
private const MARK_LEN = 4.0; // mm Länge der Schnittmarken
/** @var array<string, string> family → eingebetteter TCPDF-Fontname */
private array $fontMap = [];
public function __construct(
private readonly UrlGeneratorInterface $urls,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
public function render(Employee $employee, CardTemplate $template): string
{
$branding = $this->branding($employee);
$bleed = $template->getBleedMm();
$bgKey = $template->getBackgroundPath();
$hasBg = $bgKey && $this->cardAssets->fileExists($bgKey);
$bgReader = $hasBg ? StreamReader::createByString($this->cardAssets->read($bgKey)) : null;
// Mit Hintergrund-PDF: Seite = Endformat+Beschnitt, keine eigenen Schnittmarken
// (der Kunde liefert Beschnitt/Marken selbst). Sonst Rand für Schnittmarken.
$margin = $hasBg ? $bleed : $bleed + self::MARK_LEN;
$pw = $template->getWidthMm() + 2 * $margin;
$ph = $template->getHeightMm() + 2 * $margin;
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi('L', 'mm', [$pw, $ph], true, 'UTF-8', false);
$pdf->SetCreator('vcard4reseller');
$pdf->SetTitle(trim($employee->getFirstName().' '.$employee->getLastName()));
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetAutoPageBreak(false);
$pdf->SetMargins(0, 0, 0);
$pdf->setCellPaddings(0, 0, 0, 0);
$pdf->setCellMargins(0, 0, 0, 0);
$this->fontMap = $this->registerFonts($template);
$bgPages = $hasBg ? $pdf->setSourceFile($bgReader) : 0;
foreach ([$template->getFront(), $template->getBack()] as $i => $elements) {
$pdf->AddPage('L', [$pw, $ph]);
if ($hasBg && ($i + 1) <= $bgPages) {
$imported = $pdf->importPage($i + 1);
$pdf->useTemplate($imported, 0, 0, $pw, $ph);
}
foreach ($elements as $el) {
$this->renderElement($pdf, $el, $employee, $branding, $margin);
}
if (!$hasBg) {
$this->cropMarks($pdf, $template->getWidthMm(), $template->getHeightMm(), $bleed, $margin);
}
}
return $pdf->Output('card.pdf', 'S');
}
/**
* Bettet eigene Schriften (TTF/OTF) ein und liefert Map family TCPDF-Fontname.
*
* @return array<string, string>
*/
private function registerFonts(CardTemplate $template): array
{
$map = [];
foreach ($template->getFonts() as $f) {
$key = $f['path'] ?? '';
$family = $f['family'] ?? '';
if ('' === $family || '' === $key || !$this->cardAssets->fileExists($key)) {
continue;
}
// TCPDF braucht eine echte Datei → Schrift aus dem Storage in eine Temp-Datei
$tmp = tempnam(sys_get_temp_dir(), 'fnt');
file_put_contents($tmp, $this->cardAssets->read($key));
try {
$map[$family] = \TCPDF_FONTS::addTTFfont($tmp, 'TrueTypeUnicode', '', 32);
} catch (\Throwable) {
// nicht konvertierbar → Fallback auf Core-Font
} finally {
@unlink($tmp);
}
}
return $map;
}
/** @param array<string, mixed> $el */
private function renderElement(\TCPDF $pdf, array $el, Employee $e, array $branding, float $o): void
{
$type = $el['type'] ?? 'text';
$px = $o + (float) ($el['x'] ?? 0);
$py = $o + (float) ($el['y'] ?? 0);
$w = (float) ($el['w'] ?? 0);
$h = (float) ($el['h'] ?? 0);
switch ($type) {
case 'rect':
[$c, $m, $y, $k] = $this->color($el['fill'] ?? ['ref' => 'primary'], $branding);
$pdf->SetFillColor($c, $m, $y, $k);
$pdf->Rect($px, $py, $w, $h, 'F');
break;
case 'line':
[$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding);
$pdf->SetDrawColor($c, $m, $y, $k);
$pdf->SetLineWidth((float) ($el['lineWidth'] ?? 0.3));
$pdf->Line($px, $py, $px + $w, $py + $h);
break;
case 'qr':
$png = $this->qrPng($this->shareUrl($e));
$pdf->Image('@'.$png, $px, $py, $w, $h, 'PNG');
break;
case 'image':
$src = 'logo' === ($el['binding'] ?? null) ? ($branding['logoUrl'] ?? null) : ($el['src'] ?? null);
if ($src) {
try {
$pdf->Image($src, $px, $py, $w, $h, '', '', '', false, 300, $el['align'] ?? '');
} catch (\Throwable) {
// Logo nicht ladbar → überspringen, Karte bleibt valide
}
}
break;
case 'field':
case 'text':
$value = 'field' === $type
? $this->binding((string) ($el['binding'] ?? ''), $e)
: (string) ($el['text'] ?? '');
if ('' === trim($value)) {
return;
}
$value = ($el['prefix'] ?? '').$value;
[$c, $m, $y, $k] = $this->color($el['color'] ?? ['ref' => 'text'], $branding);
$pdf->SetTextColor($c, $m, $y, $k);
$fam = $el['fontFamily'] ?? null;
$font = $this->fontMap[$fam] ?? (in_array($fam, ['times', 'courier', 'helvetica'], true) ? $fam : 'helvetica');
$pdf->SetFont($font, !empty($el['bold']) ? 'B' : '', (float) ($el['fontSize'] ?? 9));
$pdf->MultiCell($w ?: 0, 0, $value, 0, $el['align'] ?? 'L', false, 1, $px, $py, true, 0, false, true, 0, 'T');
break;
}
}
/** Schnittmarken an den vier Endformat-Ecken (Registrierschwarz). */
private function cropMarks(\TCPDF $pdf, float $w, float $h, float $bleed, float $o): void
{
$pdf->SetDrawColor(0, 0, 0, 100);
$pdf->SetLineWidth(0.2);
$m = self::MARK_LEN;
$tl = $o; $tt = $o; $tr = $o + $w; $tb = $o + $h; // Trim-Linien
$bl = $o - $bleed; $bt = $o - $bleed; $br = $o + $w + $bleed; $bb = $o + $h + $bleed; // Bleed-Kanten
// je Ecke eine vertikale + horizontale Marke, außerhalb des Beschnitts
$pdf->Line($tl, $bt - $m, $tl, $bt); $pdf->Line($bl - $m, $tt, $bl, $tt); // oben links
$pdf->Line($tr, $bt - $m, $tr, $bt); $pdf->Line($br, $tt, $br + $m, $tt); // oben rechts
$pdf->Line($tl, $bb, $tl, $bb + $m); $pdf->Line($bl - $m, $tb, $bl, $tb); // unten links
$pdf->Line($tr, $bb, $tr, $bb + $m); $pdf->Line($br, $tb, $br + $m, $tb); // unten rechts
}
/** @return array{0:float,1:float,2:float,3:float} CMYK 0100 */
private function color(mixed $color, array $branding): array
{
if (is_array($color) && isset($color['ref'])) {
$hex = match ($color['ref']) {
'primary' => $branding['primaryColor'] ?? '#f58220',
'dark' => $branding['primaryDark'] ?? '#222222',
'text' => '#343434',
'white' => '#ffffff',
default => '#343434',
};
return $this->hexToCmyk($hex);
}
if (is_array($color) && isset($color['hex'])) {
return $this->hexToCmyk((string) $color['hex']);
}
if (is_array($color) && isset($color['c'])) {
return [(float) $color['c'], (float) $color['m'], (float) $color['y'], (float) $color['k']];
}
return [0, 0, 0, 80];
}
/** @return array{0:float,1:float,2:float,3:float} */
private function hexToCmyk(string $hex): array
{
$hex = ltrim($hex, '#');
if (6 !== strlen($hex)) {
return [0, 0, 0, 80];
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$k = 1 - max($r, $g, $b);
if ($k >= 1.0) {
return [0, 0, 0, 100];
}
$c = (1 - $r - $k) / (1 - $k);
$m = (1 - $g - $k) / (1 - $k);
$y = (1 - $b - $k) / (1 - $k);
return [round($c * 100), round($m * 100), round($y * 100), round($k * 100)];
}
private function binding(string $binding, Employee $e): string
{
return match ($binding) {
'firstName' => $e->getFirstName(),
'lastName' => $e->getLastName(),
'fullName' => trim($e->getFirstName().' '.$e->getLastName()),
'position' => (string) $e->getPosition(),
'department' => (string) $e->getDepartment(),
'email' => (string) $e->getEmail(),
'phone' => (string) $e->getPhone(),
'mobile' => (string) $e->getMobile(),
'company.name' => $e->getCompany()->getName(),
'profileUrl' => $this->profileUrl($e),
'shortUrl' => $this->shareUrl($e),
default => '',
};
}
/** @return array<string, string|null> */
private function branding(Employee $e): array
{
$b = $e->getCompany()->getBrandingConfig();
return is_array($b) ? $b : [];
}
private function qrPng(string $data): string
{
return (new Builder(
writer: new PngWriter(),
data: $data,
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 600,
margin: 0,
))->build()->getString();
}
private function shareUrl(Employee $e): string
{
if (null !== $e->getShortCode()) {
return $this->urls->generate('short_link', ['code' => $e->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL);
}
return $this->profileUrl($e);
}
private function profileUrl(Employee $e): string
{
return $this->urls->generate('public_profile', [
'companySlug' => $e->getCompany()->getSlug(),
'slug' => $e->getSlug(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Service;
use App\Entity\CardTemplate;
/**
* Liefert eine sinnvolle Standard-Visitenkarte (85×55), solange eine Firma
* noch keine eigene Vorlage im Editor angelegt hat. Koordinaten in mm im
* Trim-Raum (0,0 = obere linke Ecke der Endformat-Fläche). Farben semantisch
* (primary/dark/text/white) der Renderer löst sie aus dem Branding auf.
*/
final class CardTemplateFactory
{
public function default(): CardTemplate
{
$t = new CardTemplate();
$t->setName('Standard');
$w = $t->getWidthMm(); // 85
$h = $t->getHeightMm(); // 55
$b = $t->getBleedMm(); // 2
$t->setFront([
['type' => 'image', 'binding' => 'logo', 'x' => 6, 'y' => 6, 'w' => 36, 'h' => 11, 'align' => 'L'],
['type' => 'field', 'binding' => 'fullName', 'x' => 6, 'y' => 24, 'w' => $w - 12, 'fontSize' => 13, 'bold' => true, 'color' => ['ref' => 'dark']],
['type' => 'field', 'binding' => 'position', 'x' => 6, 'y' => 30.5, 'w' => $w - 12, 'fontSize' => 8.5, 'bold' => true, 'color' => ['ref' => 'primary']],
['type' => 'line', 'x' => 6, 'y' => 36, 'w' => 26, 'lineWidth' => 0.5, 'color' => ['ref' => 'primary']],
['type' => 'field', 'binding' => 'phone', 'prefix' => 'T ', 'x' => 6, 'y' => 39.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']],
['type' => 'field', 'binding' => 'mobile', 'prefix' => 'M ', 'x' => 6, 'y' => 43.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']],
['type' => 'field', 'binding' => 'email', 'x' => 6, 'y' => 47.5, 'w' => $w - 12, 'fontSize' => 7.5, 'color' => ['ref' => 'text']],
]);
$t->setBack([
// Vollflächiger Hintergrund inkl. Beschnitt (negative Trim-Koordinaten)
['type' => 'rect', 'x' => -$b, 'y' => -$b, 'w' => $w + 2 * $b, 'h' => $h + 2 * $b, 'fill' => ['ref' => 'primary']],
['type' => 'field', 'binding' => 'company.name', 'x' => 0, 'y' => 9, 'w' => $w, 'align' => 'C', 'fontSize' => 12, 'bold' => true, 'color' => ['ref' => 'white']],
// Weißes Panel als Kontrast für den QR-Code
['type' => 'rect', 'x' => $w / 2 - 12, 'y' => 18, 'w' => 24, 'h' => 24, 'fill' => ['ref' => 'white']],
['type' => 'qr', 'x' => $w / 2 - 10, 'y' => 20, 'w' => 20, 'h' => 20],
['type' => 'text', 'text' => 'Scan für digitales Profil', 'x' => 0, 'y' => 45, 'w' => $w, 'align' => 'C', 'fontSize' => 6, 'color' => ['ref' => 'white']],
]);
return $t;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Service;
use App\Entity\Employee;
/**
* Erzeugt eine vCard (RFC 6350, Version 3.0 breite Kompatibilität mit
* iOS, Android, Outlook) aus einem Mitarbeiterprofil.
*/
final class VCardBuilder
{
public function build(Employee $e): string
{
$company = $e->getCompany();
$location = $e->getLocation();
$lines = [];
$lines[] = 'BEGIN:VCARD';
$lines[] = 'VERSION:3.0';
$lines[] = 'N:'.$this->esc($e->getLastName()).';'.$this->esc($e->getFirstName()).';;;';
$lines[] = 'FN:'.$this->esc(trim($e->getFirstName().' '.$e->getLastName()));
if ($company) {
$lines[] = 'ORG:'.$this->esc($company->getName());
}
if ($e->getPosition()) {
$lines[] = 'TITLE:'.$this->esc($e->getPosition());
}
if ($e->getEmail()) {
$lines[] = 'EMAIL;TYPE=WORK:'.$this->esc($e->getEmail());
}
if ($e->getPhone()) {
$lines[] = 'TEL;TYPE=WORK,VOICE:'.$this->esc($e->getPhone());
}
if ($e->getMobile()) {
$lines[] = 'TEL;TYPE=CELL:'.$this->esc($e->getMobile());
}
if ($location && ($location->getStreet() || $location->getCity())) {
// ADR: ;;Straße;Ort;;PLZ;Land
$lines[] = 'ADR;TYPE=WORK:;;'
.$this->esc((string) $location->getStreet()).';'
.$this->esc((string) $location->getCity()).';;'
.$this->esc((string) $location->getPostalCode()).';'
.$this->esc((string) $location->getCountry());
}
foreach ($e->getContactLinks() as $link) {
$lines[] = 'URL:'.$this->esc($link->getUrl());
}
if ($e->getBio()) {
$lines[] = 'NOTE:'.$this->esc($e->getBio());
}
$lines[] = 'REV:'.$e->getUpdatedAt()->format('Ymd\THis\Z');
$lines[] = 'END:VCARD';
// vCard verlangt CRLF-Zeilenenden
return implode("\r\n", $lines)."\r\n";
}
/** Escaping gemäß vCard-Spezifikation. */
private function esc(string $value): string
{
return str_replace(
['\\', "\n", ',', ';'],
['\\\\', '\\n', '\\,', '\\;'],
$value,
);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Security\TenantContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Stempelt beim Schreiben (POST/PATCH) den Mandanten automatisch aus dem
* eingeloggten Kontext und verhindert Cross-Tenant-Referenzen.
* Plattform-Admins sind nicht eingeschränkt.
*
* Dekoriert den Doctrine-Persist-Processor von API Platform.
*/
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
final class TenantStampProcessor implements ProcessorInterface
{
public function __construct(
#[AutowireDecorated]
private readonly ProcessorInterface $inner,
private readonly TenantContext $tenant,
) {
}
/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$this->stampAndValidate($data);
return $this->inner->process($data, $operation, $uriVariables, $context);
}
private function stampAndValidate(mixed $data): void
{
// Plattform-Admins dürfen mandantenübergreifend schreiben
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller && $this->isTenantOwned($data)) {
throw new AccessDeniedHttpException('Kein Mandantenkontext.');
}
match (true) {
$data instanceof Company => $data->setReseller($reseller),
$data instanceof Location,
$data instanceof Domain => $this->assertCompany($data->getCompany()),
$data instanceof Employee => $this->assertEmployee($data),
$data instanceof ContactLink => $this->assertCompany($data->getEmployee()->getCompany()),
default => null,
};
}
private function assertEmployee(Employee $employee): void
{
$this->assertCompany($employee->getCompany());
// Standort muss zur selben Firma gehören
$location = $employee->getLocation();
if (null !== $location && !$location->getCompany()->getId()->equals($employee->getCompany()->getId())) {
throw new AccessDeniedHttpException('Standort gehört nicht zur Firma.');
}
}
/** Prüft, dass die referenzierte Firma im Mandanten des Nutzers liegt. */
private function assertCompany(Company $company): void
{
$reseller = $this->tenant->getReseller();
if (null === $reseller || null === $company->getReseller()
|| !$company->getReseller()->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Reseller.');
}
// Firmen-Admins dürfen nur in ihrer eigenen Firma schreiben
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Schreibzugriff nur auf die eigene Firma.');
}
}
private function isTenantOwned(mixed $data): bool
{
return $data instanceof Company
|| $data instanceof Location
|| $data instanceof Domain
|| $data instanceof Employee
|| $data instanceof ContactLink;
}
}

230
backend/symfony.lock Normal file
View File

@ -0,0 +1,230 @@
{
"api-platform/symfony": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"async-aws/async-aws-bundle": {
"version": "1.17.0"
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"league/flysystem-bundle": {
"version": "3.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
},
"files": [
"config/packages/flysystem.yaml",
"var/storage/.gitignore"
]
},
"lexik/jwt-authentication-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.5",
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
},
"files": [
"config/packages/lexik_jwt_authentication.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"symfony/console": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/maker-bundle": {
"version": "1.67",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/messenger": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}vcard4reseller{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><rect width=%22128%22 height=%22128%22 rx=%2228%22 fill=%22%23f58220%22/><text y=%221.0em%22 x=%220.5em%22 text-anchor=%22middle%22 font-size=%2284%22 font-family=%22Arial%22 font-weight=%22700%22 fill=%22%23fff%22>v</text></svg>">
<link rel="stylesheet" href="{{ asset('css/brand.css') }}">
{% block stylesheets %}{% endblock %}
{% block meta %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,154 @@
{% extends 'base.html.twig' %}
{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %}
{% set reseller = e.company.reseller %}
{# Firmenspezifisches Branding defensiv validiert #}
{% set b = e.company.brandingConfig %}
{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : null %}
{% set primaryDark = (b.primaryDark is defined and b.primaryDark matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryDark : primary %}
{% set logo = (b.logoUrl is defined and (b.logoUrl starts with 'https://' or b.logoUrl starts with '/')) ? b.logoUrl : null %}
{% block title %}{{ fullName }} {{ e.company.name }}{% endblock %}
{% block meta %}
<meta name="description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
<meta property="og:title" content="{{ fullName }}">
<meta property="og:description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
<meta property="og:type" content="profile">
{% endblock %}
{% block stylesheets %}
{% if primary %}
<style>
:root {
--psc-orange: {{ primary }};
--psc-orange-dark: {{ primaryDark }};
--psc-orange-soft: color-mix(in srgb, {{ primary }} 14%, white);
--psc-orange-soft-2: color-mix(in srgb, {{ primary }} 7%, white);
--psc-border: color-mix(in srgb, {{ primary }} 32%, white);
}
</style>
{% endif %}
<style>
.wrap { max-width: 480px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
.vc__logo { display: block; max-height: 38px; margin: 0 auto .4rem; }
.vc {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
border: 1px solid #f0f0f0;
}
.vc__cover {
height: 120px;
background: linear-gradient(135deg, var(--psc-orange) 0%, var(--psc-orange-dark) 100%);
display: flex; align-items: center; justify-content: center;
}
.vc__cover-logo { max-height: 46px; max-width: 70%; filter: brightness(0) invert(1); opacity: .95; }
.vc__head { padding: 0 1.6rem 1.4rem; margin-top: -56px; text-align: center; }
.vc__avatar {
width: 112px; height: 112px; border-radius: 50%;
border: 5px solid var(--white);
background: var(--psc-orange-soft);
color: var(--psc-orange-dark);
display: inline-flex; align-items: center; justify-content: center;
font-size: 2.4rem; font-weight: 700; object-fit: cover;
box-shadow: var(--shadow-sm);
}
.vc__name { font-size: 1.6rem; margin: .7rem 0 .15rem; }
.vc__role { color: var(--muted); font-size: 1rem; }
.vc__org {
display: inline-block; margin-top: .6rem;
background: var(--psc-orange-soft); color: var(--psc-orange-dark);
padding: .25rem .9rem; border-radius: 999px; font-weight: 600; font-size: .85rem;
}
.vc__actions { display: grid; gap: .6rem; padding: 0 1.6rem 1.4rem; }
.vc__row { display: flex; gap: .6rem; }
.vc__row .btn { flex: 1; justify-content: center; }
.btn-block { width: 100%; justify-content: center; }
.vc__section { padding: 1.2rem 1.6rem; border-top: 1px solid #f2f2f2; }
.vc__label { font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 700; margin-bottom: .7rem; }
.links { display: grid; gap: .5rem; }
.link {
display: flex; align-items: center; gap: .7rem;
padding: .7rem .9rem; border: 1px solid #eee; border-radius: var(--radius-sm);
color: var(--text); font-weight: 600; font-size: .92rem;
}
.link:hover { border-color: var(--psc-border); background: var(--psc-orange-soft-2); text-decoration: none; }
.link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--psc-orange); }
.bio { color: var(--text); font-size: .95rem; }
.qr { text-align: center; }
.qr img { width: 190px; height: 190px; border-radius: var(--radius-sm); border: 1px solid #eee; }
.qr p { color: var(--muted); font-size: .82rem; margin: .6rem 0 0; }
.foot { text-align: center; margin-top: 1.6rem; color: var(--muted); font-size: .8rem; }
.foot .brand-logo { font-size: .95rem; }
</style>
{% endblock %}
{% block body %}
<div class="wrap">
<div class="vc">
<div class="vc__cover">
{% if logo %}<img class="vc__cover-logo" src="{{ logo }}" alt="{{ e.company.name }}">{% endif %}
</div>
<div class="vc__head">
{% if e.photoPath %}
<img class="vc__avatar" src="{{ e.photoPath }}" alt="{{ fullName }}">
{% else %}
<div class="vc__avatar">{{ (e.firstName|first ~ e.lastName|first)|upper }}</div>
{% endif %}
<h1 class="vc__name">{{ fullName }}</h1>
{% if e.position or e.department %}
<div class="vc__role">{{ e.position }}{% if e.position and e.department %} · {% endif %}{{ e.department }}</div>
{% endif %}
<div class="vc__org">{{ e.company.name }}</div>
</div>
<div class="vc__actions">
<a class="btn btn-primary btn-block" href="{{ path('public_profile_vcard', {companySlug: e.company.slug, slug: e.slug}) }}">
⬇ Kontakt speichern (vCard)
</a>
<div class="vc__row">
{% if e.phone %}<a class="btn btn-soft" href="tel:{{ e.phone }}">Anrufen</a>{% endif %}
{% if e.mobile %}<a class="btn btn-soft" href="tel:{{ e.mobile }}">Mobil</a>{% endif %}
{% if e.email %}<a class="btn btn-soft" href="mailto:{{ e.email }}">E-Mail</a>{% endif %}
</div>
</div>
{% if e.bio %}
<div class="vc__section">
<div class="vc__label">Über mich</div>
<p class="bio">{{ e.bio }}</p>
</div>
{% endif %}
{% if e.contactLinks|length %}
<div class="vc__section">
<div class="vc__label">Links</div>
<div class="links">
{% for link in e.contactLinks %}
<a class="link" href="{{ link.url }}" target="_blank" rel="noopener">
<span class="dot"></span>{{ link.label ?: link.type|capitalize }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="vc__section qr">
<div class="vc__label">Profil teilen</div>
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
</div>
</div>
<div class="foot">
bereitgestellt über
<span class="brand-logo">{{ reseller ? reseller.name : 'vcard4' }}<span class="tag">reseller</span></span>
</div>
</div>
{% endblock %}

93
deploy/README.md Normal file
View File

@ -0,0 +1,93 @@
# Deployment auf Hetzner Cloud (Terraform)
Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit:
```
Internet (HTTPS)
vcard4-caddy ── TLS: Portal-Domain (automatisch) + Custom-Domains (On-Demand)
(Reverse-Proxy + Load-Balancing)
/ \
vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes, HTTP im privaten Netz)
\ /
┌─────────┴──────────┐ ┌────────┴───────────────┐
vcard4-db (MariaDB+Vol) Hetzner Object Storage (S3, Assets)
```
**Caddy** übernimmt TLS (Let's Encrypt) und Load-Balancing — kein Hetzner-LB nötig.
Für **Custom-Domains** der Firmenkunden (§11) macht Caddy *On-Demand-TLS* und fragt
vorher `GET /internal/tls-allowed?domain=…` in der App (Portal-Domain oder verifizierte
Domain aus der DB) → schützt vor unbegrenzten Zertifikatsanfragen.
App-Nodes sind zustandslos (Code + Docker), **State** liegt in DB (eigene VM) und
Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalierbar.
## Voraussetzungen (einmalig, manuell)
1. **Terraform** ≥ 1.6 + Hetzner **API-Token** (Projekt → Security → API Tokens, Read&Write).
2. **Object Storage** in der Hetzner Console anlegen: Bucket + Access-Key/Secret
(hcloud/Terraform verwalten Object Storage derzeit nicht).
3. **Git-Repo** erreichbar für die Nodes (öffentlich oder Deploy-Token in der `repo_url`).
4. **JWT-Schlüsselpaar** einmal erzeugen — auf **allen** Nodes identisch:
```bash
# lokal im backend/-Container oder mit openssl
docker compose exec php php bin/console lexik:jwt:generate-keypair --overwrite
# → backend/config/jwt/private.pem & public.pem + Passphrase aus .env (JWT_PASSPHRASE)
```
Inhalt der beiden PEM-Dateien + Passphrase in `terraform.tfvars` eintragen.
5. **DNS**: A-Record `domain → caddy_ip` (+ optional `*.zone → caddy_ip` für Subdomains).
Entweder manuell **oder** automatisch über die **Hetzner DNS API** (`manage_dns = true`
+ `hetzner_dns_token` + `dns_zone_name`; Zone muss bei Hetzner DNS liegen).
## Deploy
```bash
cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars # ausfüllen
terraform init
terraform plan
terraform apply
terraform output # load_balancer_ip etc.
```
cloud-init installiert auf jedem App-Node Docker, klont das Repo, schreibt
`.env.prod.local` + JWT-Keys, baut das SPA und startet
`deploy/compose/docker-compose.prod.yml`. Migrationen laufen **nur** auf `app-1`.
Deploy-Log auf dem Node: `/var/log/vcard4-deploy.log`.
## Skalierbarkeit verifizieren
```bash
# 1) Login über die Domain (Caddy → App-Nodes)
TOKEN=$(curl -s -X POST https://<domain>/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@vcard4reseller.de","password":"admin"}' | jq -r .token)
# 2) Health über Caddy (round-robin auf beide Nodes)
for i in $(seq 1 6); do curl -s https://<domain>/health; echo; done
# 3) Cross-Node-Beweis: Hintergrund über die Domain hochladen, dann
# auf BEIDEN Nodes rendern (per SSH) identische PDFs aus Object Storage:
ssh root@<app-1-ip> 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann'
ssh root@<app-2-ip> 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann'
```
## Skalieren
`app_count` erhöhen → `terraform apply` legt neue App-Nodes an. **Achtung:** die
Caddy-Upstreams stehen in der Caddyfile (per cloud-init beim ersten Boot gerendert) —
neue Nodes werden **nicht automatisch** aufgenommen. Optionen: Caddy-Node neu erstellen
(`terraform taint hcloud_server.caddy && apply`) oder Caddyfile auf dem Caddy-Node
aktualisieren + `docker exec caddy caddy reload`. (Später besser: Caddy-Config-Templating
per CI oder Service-Discovery.)
## Noch offen / Hinweise
- **TLS**: erledigt Caddy (Portal automatisch, Custom-Domains On-Demand). Erste
Zertifikatsausstellung dauert ein paar Sekunden nach korrektem DNS.
- **Trusted Proxies**: für korrekte absolute URLs hinter Caddy
`framework.trusted_proxies` auf `%env(TRUSTED_PROXIES)%` setzen.
- **Host-basiertes Routing** (Custom-Domain → richtige Firmenseite) ist §11-Folgearbeit;
Caddy stellt bereits Zertifikate für verifizierte Domains aus.
- **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten.
- **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript).

View File

@ -0,0 +1,25 @@
# Läuft auf jedem App-Node (zustandslos). DB & Object Storage sind extern.
# Aufruf mit --project-directory <repo> (Pfade relativ zum Repo-Root):
# docker compose --project-directory /opt/vcard4 \
# -f deploy/compose/docker-compose.prod.yml up -d --build
services:
php:
build:
context: ./docker/php
volumes:
- ./backend:/app
env_file:
- ./backend/.env.prod.local
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./backend:/app:ro # Symfony public (API + öffentliche Seiten)
- ./frontend/dist:/spa:ro # gebautes Vue-SPA (Dashboard)
- ./deploy/compose/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
restart: unless-stopped

View File

@ -0,0 +1,39 @@
# Routing auf dem App-Node:
# /api, /p, /t, /css, /bundles, /health → Symfony (PHP-FPM)
# alles andere → Vue-SPA (history-fallback)
server {
listen 80;
server_name _;
# Standard: SPA-Build
root /spa;
index index.html;
client_max_body_size 32m;
# Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte)
location ~ ^/(api|p|t|css|bundles|health|internal)(/|$) {
root /app/public;
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
root /app/public;
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
internal;
}
# SPA-Routing (history mode)
location / {
try_files $uri /index.html;
}
# direkte .php-Aufrufe blockieren
location ~ \.php$ {
return 404;
}
}

View File

@ -0,0 +1,65 @@
#cloud-config
package_update: true
write_files:
- path: /opt/secrets/.env.prod.local
permissions: '0600'
content: |
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=${app_secret}
APP_PORTAL_DOMAIN=${domain}
DATABASE_URL="${database_url}"
CORS_ALLOW_ORIGIN=${cors_allow_origin}
TRUSTED_PROXIES=10.0.0.0/16
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=${jwt_passphrase}
S3_ENDPOINT=${s3_endpoint}
S3_REGION=${s3_region}
S3_BUCKET=${s3_bucket}
S3_KEY=${s3_key}
S3_SECRET=${s3_secret}
S3_PATH_STYLE=true
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
- path: /opt/secrets/private.pem.b64
permissions: '0600'
content: ${base64encode(jwt_private_key)}
- path: /opt/secrets/public.pem.b64
permissions: '0644'
content: ${base64encode(jwt_public_key)}
- path: /opt/secrets/deploy.vars
permissions: '0600'
content: |
REPO_URL=${repo_url}
REPO_BRANCH=${repo_branch}
DOMAIN=${domain}
RUN_MIGRATIONS=${run_migrations}
- path: /opt/deploy.sh
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
. /opt/secrets/deploy.vars
export DEBIAN_FRONTEND=noninteractive
command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
apt-get update && apt-get install -y git
rm -rf /opt/vcard4
git clone --branch "$REPO_BRANCH" --depth 1 "$REPO_URL" /opt/vcard4
cd /opt/vcard4
cp /opt/secrets/.env.prod.local backend/.env.prod.local
mkdir -p backend/config/jwt
base64 -d /opt/secrets/private.pem.b64 > backend/config/jwt/private.pem
base64 -d /opt/secrets/public.pem.b64 > backend/config/jwt/public.pem
chmod 640 backend/config/jwt/private.pem
# SPA bauen (Profil-/QR-Links zeigen auf die öffentliche Domain)
docker run --rm -e VITE_PUBLIC_BASE="https://$DOMAIN" -v "$PWD/frontend":/app -w /app node:25-alpine sh -c "npm ci && npm run build"
chown -R 1000:1000 /opt/vcard4
COMPOSE="docker compose --project-directory /opt/vcard4 -f deploy/compose/docker-compose.prod.yml"
$COMPOSE up -d --build
sleep 20
if [ "$RUN_MIGRATIONS" = "true" ]; then
$COMPOSE exec -T php php bin/console doctrine:migrations:migrate --no-interaction || true
fi
$COMPOSE exec -T php php bin/console cache:clear || true
runcmd:
- bash /opt/deploy.sh > /var/log/vcard4-deploy.log 2>&1

View File

@ -0,0 +1,40 @@
#cloud-config
package_update: true
write_files:
- path: /opt/caddy/Caddyfile
permissions: '0644'
content: |
{
email ${acme_email}
on_demand_tls {
# Caddy fragt die App, ob es für die Domain ein Zertifikat ausstellen darf
ask http://${ask_upstream}/internal/tls-allowed
}
}
# Portal (Haupt-Domain): automatisches TLS, Load-Balancing über die App-Nodes
${domain}, www.${domain} {
reverse_proxy ${app_upstreams} {
lb_policy round_robin
}
}
# Custom-Domains der Firmenkunden: On-Demand-TLS (nur erlaubte Hosts)
https:// {
tls {
on_demand
}
reverse_proxy ${app_upstreams} {
lb_policy round_robin
}
}
runcmd:
- command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
- mkdir -p /opt/caddy/data /opt/caddy/config
- |
docker run -d --name caddy --restart unless-stopped \
-p 80:80 -p 443:443 -p 443:443/udp \
-v /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
-v /opt/caddy/data:/data \
-v /opt/caddy/config:/config \
caddy:2

View File

@ -0,0 +1,14 @@
#cloud-config
package_update: true
runcmd:
- command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
- mkdir -p /opt/db/data
- |
docker run -d --name mariadb --restart unless-stopped \
-p 10.0.1.10:3306:3306 \
-e MARIADB_ROOT_PASSWORD='${db_root_password}' \
-e MARIADB_DATABASE='${db_name}' \
-e MARIADB_USER='${db_user}' \
-e MARIADB_PASSWORD='${db_password}' \
-v /opt/db/data:/var/lib/mysql \
mariadb:11.4

32
deploy/terraform/dns.tf Normal file
View File

@ -0,0 +1,32 @@
# Optional: DNS-Records über die Hetzner DNS API anlegen (manage_dns = true).
# Voraussetzung: Zone liegt bei Hetzner DNS, separater DNS-API-Token.
data "hetznerdns_zone" "zone" {
count = var.manage_dns ? 1 : 0
name = var.dns_zone_name
}
locals {
# Relativer Record-Name: "@" wenn Portal == Zone, sonst der Subdomain-Teil
portal_record_name = var.domain == var.dns_zone_name ? "@" : replace(var.domain, ".${var.dns_zone_name}", "")
}
# Portal-Domain Caddy
resource "hetznerdns_record" "portal" {
count = var.manage_dns ? 1 : 0
zone_id = data.hetznerdns_zone.zone[0].id
name = local.portal_record_name
type = "A"
value = hcloud_server.caddy.ipv4_address
ttl = 300
}
# Wildcard für Firmen-Subdomains (KONZEPT §11) Caddy (On-Demand-TLS)
resource "hetznerdns_record" "wildcard" {
count = var.manage_dns ? 1 : 0
zone_id = data.hetznerdns_zone.zone[0].id
name = "*"
type = "A"
value = hcloud_server.caddy.ipv4_address
ttl = 300
}

174
deploy/terraform/main.tf Normal file
View File

@ -0,0 +1,174 @@
locals {
db_private_ip = "10.0.1.10"
caddy_private_ip = "10.0.1.5"
app_base_ip = 20 # App-Nodes: 10.0.1.20, .21, ...
database_url = "mysql://${var.db_user}:${var.db_password}@${local.db_private_ip}:3306/${var.db_name}?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
# Caddy-Upstreams = private IPs der App-Nodes (:80)
app_upstreams = join(" ", [for i in range(var.app_count) : "10.0.1.${local.app_base_ip + i}:80"])
ask_upstream = "10.0.1.${local.app_base_ip}" # app-1 für die On-Demand-TLS-Abfrage
}
resource "hcloud_ssh_key" "admin" {
name = "vcard4-admin"
public_key = var.ssh_public_key
}
# --- Privates Netz ---
resource "hcloud_network" "net" {
name = "vcard4-net"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "subnet" {
network_id = hcloud_network.net.id
type = "cloud"
network_zone = var.network_zone
ip_range = "10.0.1.0/24"
}
# --- Firewalls ---
resource "hcloud_firewall" "app" {
name = "vcard4-app-fw"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["10.0.0.0/16"] # nur aus dem privaten Netz (Load Balancer)
}
}
resource "hcloud_firewall" "db" {
name = "vcard4-db-fw"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "3306"
source_ips = ["10.0.0.0/16"] # nur App-Nodes
}
}
# --- DB-Node ---
resource "hcloud_server" "db" {
name = "vcard4-db"
server_type = var.db_server_type
image = "ubuntu-24.04"
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.db.id]
user_data = templatefile("${path.module}/cloud-init-db.yaml.tftpl", {
db_name = var.db_name
db_user = var.db_user
db_password = var.db_password
db_root_password = var.db_root_password
})
network {
network_id = hcloud_network.net.id
ip = local.db_private_ip
}
depends_on = [hcloud_network_subnet.subnet]
}
# --- App-Nodes (zustandslos) ---
resource "hcloud_server" "app" {
count = var.app_count
name = "vcard4-app-${count.index + 1}"
server_type = var.app_server_type
image = "ubuntu-24.04"
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.app.id]
user_data = templatefile("${path.module}/cloud-init-app.yaml.tftpl", {
repo_url = var.repo_url
repo_branch = var.repo_branch
run_migrations = count.index == 0 # Migrationen nur auf dem ersten Node
app_secret = var.app_secret
database_url = local.database_url
domain = var.domain
cors_allow_origin = "^https?://(www\\.)?${replace(var.domain, ".", "\\.")}$"
jwt_passphrase = var.jwt_passphrase
jwt_private_key = var.jwt_private_key
jwt_public_key = var.jwt_public_key
s3_endpoint = var.s3_endpoint
s3_region = var.s3_region
s3_bucket = var.s3_bucket
s3_key = var.s3_key
s3_secret = var.s3_secret
})
network {
network_id = hcloud_network.net.id
ip = "10.0.1.${local.app_base_ip + count.index}"
}
depends_on = [hcloud_network_subnet.subnet, hcloud_server.db]
}
# --- Caddy-Edge (TLS-Terminierung + Reverse-Proxy/Load-Balancing) ---
resource "hcloud_firewall" "caddy" {
name = "vcard4-caddy-fw"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "udp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
resource "hcloud_server" "caddy" {
name = "vcard4-caddy"
server_type = var.app_server_type
image = "ubuntu-24.04"
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.caddy.id]
user_data = templatefile("${path.module}/cloud-init-caddy.yaml.tftpl", {
acme_email = var.acme_email
domain = var.domain
app_upstreams = local.app_upstreams
ask_upstream = local.ask_upstream
})
network {
network_id = hcloud_network.net.id
ip = local.caddy_private_ip
}
depends_on = [hcloud_network_subnet.subnet, hcloud_server.app]
}

View File

@ -0,0 +1,14 @@
output "caddy_ip" {
description = "Öffentliche IP des Caddy-Edge → hierauf den DNS-A-Record (Portal + *.) setzen"
value = hcloud_server.caddy.ipv4_address
}
output "app_server_ips" {
description = "Öffentliche IPs der App-Nodes (SSH/Debug)"
value = hcloud_server.app[*].ipv4_address
}
output "db_server_ip" {
description = "Öffentliche IP des DB-Nodes (SSH)"
value = hcloud_server.db.ipv4_address
}

View File

@ -0,0 +1,48 @@
# Kopiere nach terraform.tfvars und fülle die Werte. NICHT committen (steht in .gitignore).
hcloud_token = "DEIN_HETZNER_API_TOKEN"
ssh_public_key = "ssh-ed25519 AAAA... dein-key"
admin_cidr = "1.2.3.4/32" # deine IP für SSH
location = "nbg1"
network_zone = "eu-central"
app_count = 2
app_server_type = "cx22"
db_server_type = "cx22"
# Anwendung
repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL
repo_branch = "main"
domain = "test.example.com" # Portal-Domain (ins Portal einloggen)
acme_email = "admin@example.com" # Let's Encrypt
app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16
# DNS optional über Hetzner DNS API (sonst A-Record manuell auf caddy_ip setzen)
manage_dns = false
hetzner_dns_token = "" # separater DNS-API-Token
dns_zone_name = "" # z. B. example.com
# Datenbank
db_password = "STARKES_PASSWORT"
db_root_password = "STARKES_ROOT_PASSWORT"
# JWT (einmal erzeugen, identisch für alle Nodes siehe README)
jwt_passphrase = "DEINE_PASSPHRASE"
jwt_private_key = <<-EOT
-----BEGIN ENCRYPTED PRIVATE KEY-----
...
-----END ENCRYPTED PRIVATE KEY-----
EOT
jwt_public_key = <<-EOT
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
EOT
# Hetzner Object Storage (Bucket + Keys vorab in der Console anlegen)
s3_endpoint = "https://nbg1.your-objectstorage.com"
s3_region = "nbg1"
s3_bucket = "vcard4-card-assets"
s3_key = "OBJECT_STORAGE_ACCESS_KEY"
s3_secret = "OBJECT_STORAGE_SECRET_KEY"

View File

@ -0,0 +1,156 @@
variable "hcloud_token" {
description = "Hetzner Cloud API Token (Projekt → Security → API Tokens, Read&Write)"
type = string
sensitive = true
}
variable "location" {
description = "Hetzner Standort"
type = string
default = "nbg1"
}
variable "network_zone" {
description = "Netzwerk-Zone passend zum Standort (eu-central für nbg1/fsn1/hel1)"
type = string
default = "eu-central"
}
variable "ssh_public_key" {
description = "Öffentlicher SSH-Schlüssel für Server-Zugang"
type = string
}
variable "admin_cidr" {
description = "CIDR, das per SSH auf die Server darf (z. B. deine IP/32)"
type = string
}
variable "app_count" {
description = "Anzahl App-Nodes (für den Skalierungstest >= 2)"
type = number
default = 2
}
variable "app_server_type" {
description = "Servertyp App-Nodes"
type = string
default = "cx22"
}
variable "db_server_type" {
description = "Servertyp DB-Node"
type = string
default = "cx22"
}
# --- Anwendung / Deploy ---
variable "repo_url" {
description = "Git-URL des Repos (per cloud-init geklont; bei privat: Deploy-Token in der URL)"
type = string
}
variable "repo_branch" {
description = "Zu deployender Branch"
type = string
default = "main"
}
variable "domain" {
description = "Öffentliche Portal-Domain (CORS, Profil-URLs, TLS)"
type = string
}
variable "acme_email" {
description = "E-Mail für Let's Encrypt (Caddy ACME)"
type = string
}
# --- DNS (optional, Hetzner DNS API) ---
variable "manage_dns" {
description = "true = A-Records (Portal + Wildcard) per Hetzner DNS anlegen"
type = bool
default = false
}
variable "hetzner_dns_token" {
description = "Hetzner DNS API Token (separat vom Cloud-Token; nur bei manage_dns)"
type = string
default = ""
sensitive = true
}
variable "dns_zone_name" {
description = "DNS-Zone bei Hetzner DNS (z. B. example.com), nur bei manage_dns"
type = string
default = ""
}
variable "app_secret" {
description = "Symfony APP_SECRET"
type = string
sensitive = true
}
variable "db_name" {
type = string
default = "vcard4reseller"
}
variable "db_user" {
type = string
default = "app"
}
variable "db_password" {
type = string
sensitive = true
}
variable "db_root_password" {
type = string
sensitive = true
}
variable "jwt_passphrase" {
description = "Passphrase der JWT-Schlüssel (identisch zu den erzeugten Keys)"
type = string
sensitive = true
}
variable "jwt_private_key" {
description = "Inhalt von config/jwt/private.pem (auf ALLEN Nodes identisch)"
type = string
sensitive = true
}
variable "jwt_public_key" {
description = "Inhalt von config/jwt/public.pem"
type = string
sensitive = true
}
# --- Hetzner Object Storage (S3) ---
variable "s3_endpoint" {
description = "z. B. https://nbg1.your-objectstorage.com"
type = string
}
variable "s3_region" {
type = string
default = "nbg1"
}
variable "s3_bucket" {
type = string
}
variable "s3_key" {
type = string
sensitive = true
}
variable "s3_secret" {
type = string
sensitive = true
}

View File

@ -0,0 +1,21 @@
terraform {
required_version = ">= 1.6"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.48"
}
hetznerdns = {
source = "germanbrew/hetznerdns"
version = "~> 3.0"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
provider "hetznerdns" {
api_token = var.hetzner_dns_token
}

94
docker-compose.yml Normal file
View File

@ -0,0 +1,94 @@
services:
php:
build:
context: ./docker/php
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
volumes:
- ./backend:/app
depends_on:
mariadb:
condition: service_healthy
environment:
DATABASE_URL: "mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
# Zweiter, identischer App-Node — zum Beweis, dass Assets/Auth nodeübergreifend laufen
php2:
build:
context: ./docker/php
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
volumes:
- ./backend:/app
depends_on:
mariadb:
condition: service_healthy
environment:
DATABASE_URL: "mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
profiles: ["scale-test"]
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./backend:/app:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
# Legt den Bucket einmalig an
minio-init:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
mc mb -p local/card-assets || true;
"
mariadb:
image: mariadb:11.4
ports:
- "3306:3306"
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: vcard4reseller
MARIADB_USER: app
MARIADB_PASSWORD: app
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 10
node:
image: node:25-alpine
working_dir: /app
volumes:
- ./frontend:/app
ports:
- "5173:5173"
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
profiles: ["frontend"]
volumes:
mariadb_data:
minio_data:

Some files were not shown because too many files have changed in this diff Show More