Stack & Setup
- Dockerisierte Dev-Umgebung (PHP 8.4-FPM, Nginx, MariaDB 11.4)
- Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- Vue 3 + TS (Vite), Vue Router, Pinia, Axios
Kern-Domäne & Auth
- Entitäten: User, PlatformPlan, Reseller, Company, Domain, Location,
Employee, ContactLink (UUIDv7)
- JWT-Login (/api/login), Rollen-Hierarchie, /api/me
- Mandantentrennung via API-Platform-Query-Extension (Lesen) +
TenantStampProcessor (Schreiben)
Öffentliche Profile (SSR)
- Profil-Landingpage, vCard-Download, QR-Code im Marken-Look
- Stabiler NFC/QR-Kurz-Link /t/{code} -> Redirect aufs aktuelle Profil
- Firmenspezifisches Branding (Farben/Logo) auf der Profilseite
Verwaltungsoberfläche (SPA)
- Brand-Look (dunkle Sidebar), rollenbasierte Navigation
- Dashboard, Reseller (+Provisioning), Firmen, Mitarbeiter, Standorte,
Domains, Design/Branding mit Live-Vorschau
Konzept & Doku: docs/KONZEPT.md (inkl. Wallet/Sync §12), README.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
2.2 KiB
PHP
74 lines
2.2 KiB
PHP
<?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,
|
||
);
|
||
}
|
||
}
|