vcard4reseller/backend/src/Controller/BrandingAdminController.php
Thomas Peterson a233c34599 White-Label Phase 3: Branding-Verwaltung
- BrandingAdminController: GET/PUT /api/my-branding (Farben, Tagline),
  Logo-Upload/-Löschen, öffentliche Logo-Auslieferung /api/branding/logo/...
- BrandingService liefert logoUrl aus S3-logoKey (Firma → Reseller → Default)
- BrandingView (Reseller-Topnav + Firmen-Sidebar): Farbwähler, Slogan,
  Logo-Upload mit Live-Vorschau, Anzeige der eigenen Adresse

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:15:27 +02:00

195 lines
7.6 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Security\TenantContext;
use App\Service\BrandingService;
use App\Service\TenantResolver;
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\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;
/**
* White-Label-Branding pflegen: Reseller-Admin brandet seinen Reseller,
* Firmen-Admin seine Firma (Farben, Tagline, Logo). Plus öffentliche Logo-Auslieferung.
*/
final class BrandingAdminController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
private readonly TenantResolver $resolver,
private readonly BrandingService $branding,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/my-branding', name: 'my_branding_get', methods: ['GET'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function get(): JsonResponse
{
return new JsonResponse($this->serialize());
}
#[Route('/api/my-branding', name: 'my_branding_put', methods: ['PUT'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function put(Request $request): JsonResponse
{
[, $entity] = $this->target();
$data = json_decode($request->getContent(), true) ?? [];
$cfg = $entity->getBrandingConfig();
foreach (['primaryColor', 'primaryColorDark', 'primaryColorSoft'] as $key) {
if (\array_key_exists($key, $data)) {
$hex = $this->hex($data[$key] ?? null);
if (null !== $hex) {
$cfg[$key] = $hex;
} else {
unset($cfg[$key]);
}
}
}
if (\array_key_exists('tagline', $data)) {
$tag = trim((string) ($data['tagline'] ?? ''));
if ('' !== $tag) {
$cfg['tagline'] = mb_substr($tag, 0, 120);
} else {
unset($cfg['tagline']);
}
}
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize());
}
#[Route('/api/my-branding/logo', name: 'my_branding_logo_post', methods: ['POST'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function uploadLogo(Request $request): JsonResponse
{
[$scope, $entity] = $this->target();
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file").');
}
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'svg'], true)) {
throw new BadRequestHttpException('Nur PNG, JPG oder SVG erlaubt.');
}
$cfg = $entity->getBrandingConfig();
$old = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
$key = sprintf('branding/%s-%s/logo-%s.%s', $scope, $entity->getId()->toRfc4122(), bin2hex(random_bytes(4)), $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
$this->cardAssets->delete($old);
}
$cfg['logoKey'] = $key;
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize(), 201);
}
#[Route('/api/my-branding/logo', name: 'my_branding_logo_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function deleteLogo(): JsonResponse
{
[, $entity] = $this->target();
$cfg = $entity->getBrandingConfig();
$key = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
if (null !== $key && $this->cardAssets->fileExists($key)) {
$this->cardAssets->delete($key);
}
unset($cfg['logoKey']);
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize());
}
/** Öffentliche Logo-Auslieferung (vom Branding-Endpoint verlinkt). */
#[Route('/api/branding/logo/{scope}/{id}.png', name: 'branding_logo', methods: ['GET'])]
public function logo(string $scope, string $id): Response
{
$entity = $this->load($scope, $id);
$key = \is_string($entity->getBrandingConfig()['logoKey'] ?? null) ? $entity->getBrandingConfig()['logoKey'] : null;
if (null === $key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Logo.');
}
$bytes = $this->cardAssets->read($key);
$mime = str_ends_with($key, '.svg') ? 'image/svg+xml' : (str_ends_with($key, '.png') ? 'image/png' : 'image/jpeg');
return new Response($bytes, 200, ['Content-Type' => $mime, 'Cache-Control' => 'public, max-age=86400']);
}
/** @return array{0:string,1:Company|Reseller} */
private function target(): array
{
$company = $this->tenant->getCompany();
if ($company instanceof Company) {
return [BrandingService::SCOPE_COMPANY, $company];
}
$reseller = $this->tenant->getReseller();
if ($reseller instanceof Reseller) {
return [BrandingService::SCOPE_RESELLER, $reseller];
}
throw new AccessDeniedHttpException('Kein Branding-Kontext.');
}
private function load(string $scope, string $id): Company|Reseller
{
$uuid = Uuid::fromString($id);
$entity = BrandingService::SCOPE_COMPANY === $scope
? $this->em->getRepository(Company::class)->find($uuid)
: $this->em->getRepository(Reseller::class)->find($uuid);
if (!$entity instanceof Company && !$entity instanceof Reseller) {
throw new NotFoundHttpException('Nicht gefunden.');
}
return $entity;
}
/** @return array<string, mixed> */
private function serialize(): array
{
[$scope, $entity] = $this->target();
$cfg = $entity->getBrandingConfig();
$tenant = $entity instanceof Company
? new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_COMPANY, $entity->getReseller(), $entity)
: new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_RESELLER, $entity);
$host = $entity instanceof Company ? $this->resolver->companyHost($entity) : $this->resolver->resellerHost($entity);
return [
'scope' => $scope,
'name' => $entity->getName(),
'address' => $host,
'primaryColor' => $cfg['primaryColor'] ?? '#f58220',
'primaryColorDark' => $cfg['primaryColorDark'] ?? '#d96500',
'primaryColorSoft' => $cfg['primaryColorSoft'] ?? '#fff2e7',
'tagline' => $cfg['tagline'] ?? '',
'hasLogo' => '' !== (string) ($cfg['logoKey'] ?? ''),
'logoUrl' => $this->branding->logoUrl($tenant),
];
}
private function hex(mixed $value): ?string
{
return (\is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null;
}
}