Compare commits
10 Commits
03407b76e3
...
79e996ab03
| Author | SHA1 | Date | |
|---|---|---|---|
| 79e996ab03 | |||
| c3e05257cb | |||
| 67e4353c8d | |||
| 73f05ed7e7 | |||
| 904a4184fc | |||
| f25ccefa48 | |||
| b52d696cc5 | |||
| 1a035d6c61 | |||
| 408b37a5ea | |||
| ebaf509a2f |
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
119
README.md
Normal 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
17
backend/.editorconfig
Normal 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
69
backend/.env
Normal 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
4
backend/.env.dev
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_SECRET=6a99dc78ab52a33deba7f8bd986720dc
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
14
backend/.gitignore
vendored
Normal file
14
backend/.gitignore
vendored
Normal 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
21
backend/bin/console
Executable 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);
|
||||||
|
};
|
||||||
7
backend/compose.override.yaml
Normal file
7
backend/compose.override.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
database:
|
||||||
|
ports:
|
||||||
|
- "5432"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
25
backend/compose.yaml
Normal file
25
backend/compose.yaml
Normal 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
95
backend/composer.json
Normal 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
9203
backend/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
backend/config/bundles.php
Normal file
15
backend/config/bundles.php
Normal 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],
|
||||||
|
];
|
||||||
7
backend/config/packages/api_platform.yaml
Normal file
7
backend/config/packages/api_platform.yaml
Normal 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']
|
||||||
11
backend/config/packages/async_aws.yaml
Normal file
11
backend/config/packages/async_aws.yaml
Normal 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)%'
|
||||||
19
backend/config/packages/cache.yaml
Normal file
19
backend/config/packages/cache.yaml
Normal 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
|
||||||
46
backend/config/packages/doctrine.yaml
Normal file
46
backend/config/packages/doctrine.yaml
Normal 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
|
||||||
6
backend/config/packages/doctrine_migrations.yaml
Normal file
6
backend/config/packages/doctrine_migrations.yaml
Normal 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
|
||||||
11
backend/config/packages/flysystem.yaml
Normal file
11
backend/config/packages/flysystem.yaml
Normal 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'
|
||||||
15
backend/config/packages/framework.yaml
Normal file
15
backend/config/packages/framework.yaml
Normal 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
|
||||||
4
backend/config/packages/lexik_jwt_authentication.yaml
Normal file
4
backend/config/packages/lexik_jwt_authentication.yaml
Normal 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)%'
|
||||||
22
backend/config/packages/messenger.yaml
Normal file
22
backend/config/packages/messenger.yaml
Normal 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://'
|
||||||
10
backend/config/packages/nelmio_cors.yaml
Normal file
10
backend/config/packages/nelmio_cors.yaml
Normal 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
|
||||||
3
backend/config/packages/property_info.yaml
Normal file
3
backend/config/packages/property_info.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
10
backend/config/packages/routing.yaml
Normal file
10
backend/config/packages/routing.yaml
Normal 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
|
||||||
55
backend/config/packages/security.yaml
Normal file
55
backend/config/packages/security.yaml
Normal 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
|
||||||
6
backend/config/packages/twig.yaml
Normal file
6
backend/config/packages/twig.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
11
backend/config/packages/validator.yaml
Normal file
11
backend/config/packages/validator.yaml
Normal 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
|
||||||
5
backend/config/preload.php
Normal file
5
backend/config/preload.php
Normal 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';
|
||||||
|
}
|
||||||
11
backend/config/routes.yaml
Normal file
11
backend/config/routes.yaml
Normal 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
|
||||||
4
backend/config/routes/api_platform.yaml
Normal file
4
backend/config/routes/api_platform.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
api_platform:
|
||||||
|
resource: .
|
||||||
|
type: api_platform
|
||||||
|
prefix: /api
|
||||||
4
backend/config/routes/framework.yaml
Normal file
4
backend/config/routes/framework.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
|
prefix: /_error
|
||||||
3
backend/config/routes/security.yaml
Normal file
3
backend/config/routes/security.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
||||||
23
backend/config/services.yaml
Normal file
23
backend/config/services.yaml
Normal 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
0
backend/migrations/.gitignore
vendored
Normal file
65
backend/migrations/Version20260530191712.php
Normal file
65
backend/migrations/Version20260530191712.php
Normal 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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/migrations/Version20260531085615.php
Normal file
33
backend/migrations/Version20260531085615.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/migrations/Version20260531092327.php
Normal file
33
backend/migrations/Version20260531092327.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/migrations/Version20260531150051.php
Normal file
34
backend/migrations/Version20260531150051.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/public/css/brand.css
Normal file
82
backend/public/css/brand.css
Normal 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
9
backend/public/index.php
Normal 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
0
backend/src/ApiResource/.gitignore
vendored
Normal file
37
backend/src/Command/BackfillShortCodesCommand.php
Normal file
37
backend/src/Command/BackfillShortCodesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/Command/RenderCardCommand.php
Normal file
62
backend/src/Command/RenderCardCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend/src/Command/SeedCommand.php
Normal file
121
backend/src/Command/SeedCommand.php
Normal 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
0
backend/src/Controller/.gitignore
vendored
Normal file
153
backend/src/Controller/CardAssetUploadController.php
Normal file
153
backend/src/Controller/CardAssetUploadController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/Controller/CardPdfController.php
Normal file
65
backend/src/Controller/CardPdfController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/src/Controller/CardTemplateEditorController.php
Normal file
102
backend/src/Controller/CardTemplateEditorController.php
Normal 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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
77
backend/src/Controller/CompanyBrandingController.php
Normal file
77
backend/src/Controller/CompanyBrandingController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/Controller/HealthController.php
Normal file
29
backend/src/Controller/HealthController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/Controller/MeController.php
Normal file
38
backend/src/Controller/MeController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/src/Controller/PublicProfileController.php
Normal file
97
backend/src/Controller/PublicProfileController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/Controller/ResellerProvisioningController.php
Normal file
79
backend/src/Controller/ResellerProvisioningController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/Controller/SecurityController.php
Normal file
20
backend/src/Controller/SecurityController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/Controller/ShortLinkController.php
Normal file
34
backend/src/Controller/ShortLinkController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/src/Controller/TlsCheckController.php
Normal file
44
backend/src/Controller/TlsCheckController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backend/src/Doctrine/TenantExtension.php
Normal file
120
backend/src/Doctrine/TenantExtension.php
Normal 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
0
backend/src/Entity/.gitignore
vendored
Normal file
232
backend/src/Entity/CardTemplate.php
Normal file
232
backend/src/Entity/CardTemplate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
backend/src/Entity/Company.php
Normal file
182
backend/src/Entity/Company.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend/src/Entity/ContactLink.php
Normal file
113
backend/src/Entity/ContactLink.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
134
backend/src/Entity/Domain.php
Normal file
134
backend/src/Entity/Domain.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
374
backend/src/Entity/Employee.php
Normal file
374
backend/src/Entity/Employee.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/src/Entity/Location.php
Normal file
173
backend/src/Entity/Location.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backend/src/Entity/PlatformPlan.php
Normal file
131
backend/src/Entity/PlatformPlan.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
backend/src/Entity/Reseller.php
Normal file
145
backend/src/Entity/Reseller.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/Entity/ResellerOwnedInterface.php
Normal file
13
backend/src/Entity/ResellerOwnedInterface.php
Normal 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
183
backend/src/Entity/User.php
Normal 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
11
backend/src/Kernel.php
Normal 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
0
backend/src/Repository/.gitignore
vendored
Normal file
25
backend/src/Repository/CardTemplateRepository.php
Normal file
25
backend/src/Repository/CardTemplateRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/CompanyRepository.php
Normal file
18
backend/src/Repository/CompanyRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/ContactLinkRepository.php
Normal file
18
backend/src/Repository/ContactLinkRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/Repository/DomainRepository.php
Normal file
24
backend/src/Repository/DomainRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/Repository/EmployeeRepository.php
Normal file
51
backend/src/Repository/EmployeeRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/LocationRepository.php
Normal file
18
backend/src/Repository/LocationRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/PlatformPlanRepository.php
Normal file
18
backend/src/Repository/PlatformPlanRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/ResellerRepository.php
Normal file
18
backend/src/Repository/ResellerRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/Repository/UserRepository.php
Normal file
32
backend/src/Repository/UserRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/Security/TenantContext.php
Normal file
38
backend/src/Security/TenantContext.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
281
backend/src/Service/CardPdfRenderer.php
Normal file
281
backend/src/Service/CardPdfRenderer.php
Normal 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 0–100 */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/Service/CardTemplateFactory.php
Normal file
45
backend/src/Service/CardTemplateFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/Service/VCardBuilder.php
Normal file
73
backend/src/Service/VCardBuilder.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/src/State/TenantStampProcessor.php
Normal file
102
backend/src/State/TenantStampProcessor.php
Normal 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
230
backend/symfony.lock
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/templates/base.html.twig
Normal file
16
backend/templates/base.html.twig
Normal 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>
|
||||||
154
backend/templates/public/profile.html.twig
Normal file
154
backend/templates/public/profile.html.twig
Normal 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
93
deploy/README.md
Normal 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).
|
||||||
25
deploy/compose/docker-compose.prod.yml
Normal file
25
deploy/compose/docker-compose.prod.yml
Normal 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
|
||||||
39
deploy/compose/nginx.prod.conf
Normal file
39
deploy/compose/nginx.prod.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
deploy/terraform/cloud-init-app.yaml.tftpl
Normal file
65
deploy/terraform/cloud-init-app.yaml.tftpl
Normal 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
|
||||||
40
deploy/terraform/cloud-init-caddy.yaml.tftpl
Normal file
40
deploy/terraform/cloud-init-caddy.yaml.tftpl
Normal 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
|
||||||
14
deploy/terraform/cloud-init-db.yaml.tftpl
Normal file
14
deploy/terraform/cloud-init-db.yaml.tftpl
Normal 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
32
deploy/terraform/dns.tf
Normal 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
174
deploy/terraform/main.tf
Normal 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]
|
||||||
|
}
|
||||||
14
deploy/terraform/outputs.tf
Normal file
14
deploy/terraform/outputs.tf
Normal 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
|
||||||
|
}
|
||||||
48
deploy/terraform/terraform.tfvars.example
Normal file
48
deploy/terraform/terraform.tfvars.example
Normal 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"
|
||||||
156
deploy/terraform/variables.tf
Normal file
156
deploy/terraform/variables.tf
Normal 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
|
||||||
|
}
|
||||||
21
deploy/terraform/versions.tf
Normal file
21
deploy/terraform/versions.tf
Normal 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
94
docker-compose.yml
Normal 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
Loading…
Reference in New Issue
Block a user