feat: Permettre la personnalisation du logo et de la couleur principale de l'établissement
Les administrateurs peuvent désormais configurer l'identité visuelle de leur établissement : upload d'un logo (PNG/JPG, redimensionné automatiquement via Imagick) et choix d'une couleur principale appliquée aux boutons et à la navigation. La couleur est validée côté client et serveur pour garantir la conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les personnalisations sont injectées dynamiquement via CSS variables et visibles immédiatement après sauvegarde.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoCommand;
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoHandler;
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer le logo (DELETE /school/branding/logo).
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, null>
|
||||
*/
|
||||
final readonly class DeleteLogoProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteLogoHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (SchoolBrandingNotFoundException) {
|
||||
throw new NotFoundHttpException('Branding non trouvé pour cet établissement.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingCommand;
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingHandler;
|
||||
use App\Administration\Domain\Exception\BrandColorInvalideException;
|
||||
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier les couleurs du branding.
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class UpdateBrandingColorsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateBrandingHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
primaryColor: $data->primaryColor,
|
||||
secondaryColor: $data->secondaryColor,
|
||||
accentColor: $data->accentColor,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
} catch (BrandColorInvalideException|ContrasteInsuffisantException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoCommand;
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoHandler;
|
||||
use App\Administration\Domain\Exception\LogoFormatInvalideException;
|
||||
use App\Administration\Domain\Exception\LogoTraitementImpossibleException;
|
||||
use App\Administration\Domain\Exception\LogoTropGrosException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour l'upload du logo (POST /school/branding/logo).
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class UploadLogoProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UploadLogoHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new BadRequestHttpException('Le fichier du logo est requis.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
} catch (LogoTropGrosException|LogoFormatInvalideException|LogoTraitementImpossibleException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer la configuration branding de l'établissement.
|
||||
*
|
||||
* @implements ProviderInterface<SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class SchoolBrandingProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir la configuration du branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId));
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteLogoProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateBrandingColorsProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UploadLogoProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\SchoolBrandingProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion du branding de l'établissement.
|
||||
*
|
||||
* @see FR83 - Configurer logo et couleurs établissement
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'SchoolBranding',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/school/branding',
|
||||
provider: SchoolBrandingProvider::class,
|
||||
name: 'get_school_branding',
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/school/branding',
|
||||
read: false,
|
||||
processor: UpdateBrandingColorsProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_school_branding',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/school/branding/logo',
|
||||
read: false,
|
||||
deserialize: false,
|
||||
processor: UploadLogoProcessor::class,
|
||||
name: 'upload_school_logo',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/school/branding/logo',
|
||||
read: false,
|
||||
processor: DeleteLogoProcessor::class,
|
||||
name: 'delete_school_logo',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class SchoolBrandingResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $schoolId = null;
|
||||
|
||||
public ?string $logoUrl = null;
|
||||
|
||||
public ?DateTimeImmutable $logoUpdatedAt = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $primaryColor = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $secondaryColor = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $accentColor = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromDomain(SchoolBranding $branding): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->schoolId = (string) $branding->schoolId;
|
||||
$resource->logoUrl = $branding->logoUrl !== null ? (string) $branding->logoUrl : null;
|
||||
$resource->logoUpdatedAt = $branding->logoUpdatedAt;
|
||||
$resource->primaryColor = $branding->primaryColor !== null ? (string) $branding->primaryColor : null;
|
||||
$resource->secondaryColor = $branding->secondaryColor !== null ? (string) $branding->secondaryColor : null;
|
||||
$resource->accentColor = $branding->accentColor !== null ? (string) $branding->accentColor : null;
|
||||
$resource->updatedAt = $branding->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineSchoolBrandingRepository implements SchoolBrandingRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolBranding $branding): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO school_branding (school_id, tenant_id, logo_url, logo_updated_at, primary_color, secondary_color, accent_color, created_at, updated_at)
|
||||
VALUES (:school_id, :tenant_id, :logo_url, :logo_updated_at, :primary_color, :secondary_color, :accent_color, :created_at, :updated_at)
|
||||
ON CONFLICT (school_id) DO UPDATE SET
|
||||
logo_url = EXCLUDED.logo_url,
|
||||
logo_updated_at = EXCLUDED.logo_updated_at,
|
||||
primary_color = EXCLUDED.primary_color,
|
||||
secondary_color = EXCLUDED.secondary_color,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'school_id' => (string) $branding->schoolId,
|
||||
'tenant_id' => (string) $branding->tenantId,
|
||||
'logo_url' => $branding->logoUrl !== null ? (string) $branding->logoUrl : null,
|
||||
'logo_updated_at' => $branding->logoUpdatedAt?->format(DateTimeImmutable::ATOM),
|
||||
'primary_color' => $branding->primaryColor !== null ? (string) $branding->primaryColor : null,
|
||||
'secondary_color' => $branding->secondaryColor !== null ? (string) $branding->secondaryColor : null,
|
||||
'accent_color' => $branding->accentColor !== null ? (string) $branding->accentColor : null,
|
||||
'created_at' => $branding->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $branding->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding
|
||||
{
|
||||
$branding = $this->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
throw SchoolBrandingNotFoundException::pourEcole($schoolId);
|
||||
}
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_branding WHERE school_id = :school_id AND tenant_id = :tenant_id',
|
||||
[
|
||||
'school_id' => (string) $schoolId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): SchoolBranding
|
||||
{
|
||||
/** @var string $schoolId */
|
||||
$schoolId = $row['school_id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string|null $logoUrl */
|
||||
$logoUrl = $row['logo_url'];
|
||||
/** @var string|null $logoUpdatedAt */
|
||||
$logoUpdatedAt = $row['logo_updated_at'];
|
||||
/** @var string|null $primaryColor */
|
||||
$primaryColor = $row['primary_color'];
|
||||
/** @var string|null $secondaryColor */
|
||||
$secondaryColor = $row['secondary_color'];
|
||||
/** @var string|null $accentColor */
|
||||
$accentColor = $row['accent_color'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return SchoolBranding::reconstitute(
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
logoUrl: $logoUrl !== null ? new LogoUrl($logoUrl) : null,
|
||||
logoUpdatedAt: $logoUpdatedAt !== null ? new DateTimeImmutable($logoUpdatedAt) : null,
|
||||
primaryColor: $primaryColor !== null ? new BrandColor($primaryColor) : null,
|
||||
secondaryColor: $secondaryColor !== null ? new BrandColor($secondaryColor) : null,
|
||||
accentColor: $accentColor !== null ? new BrandColor($accentColor) : null,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemorySchoolBrandingRepository implements SchoolBrandingRepository
|
||||
{
|
||||
/** @var array<string, SchoolBranding> Indexed by tenant:school */
|
||||
private array $byKey = [];
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolBranding $branding): void
|
||||
{
|
||||
$this->byKey[$this->key($branding->schoolId, $branding->tenantId)] = $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding
|
||||
{
|
||||
$branding = $this->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
throw SchoolBrandingNotFoundException::pourEcole($schoolId);
|
||||
}
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding
|
||||
{
|
||||
return $this->byKey[$this->key($schoolId, $tenantId)] ?? null;
|
||||
}
|
||||
|
||||
private function key(SchoolId $schoolId, TenantId $tenantId): string
|
||||
{
|
||||
return $tenantId . ':' . $schoolId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur le branding établissement.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
|
||||
* - PROF, VIE_SCOLAIRE, SECRETARIAT : lecture seule
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class SchoolBrandingVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'BRANDING_VIEW';
|
||||
public const string CONFIGURE = 'BRANDING_CONFIGURE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CONFIGURE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CONFIGURE => $this->canConfigure($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canConfigure(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use App\Administration\Domain\Exception\LogoTraitementImpossibleException;
|
||||
use Imagick;
|
||||
use ImagickException;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Redimensionne les images via ImageMagick (ext-imagick).
|
||||
*
|
||||
* Conserve les proportions : l'image résultante tient dans
|
||||
* la boîte maxWidth × maxHeight sans être déformée.
|
||||
* Le résultat est toujours au format PNG.
|
||||
*/
|
||||
final class ImagickImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
try {
|
||||
$image = new Imagick($sourcePath);
|
||||
|
||||
$image->thumbnailImage($maxWidth, $maxHeight, true);
|
||||
$image->setImageFormat('png');
|
||||
|
||||
$content = $image->getImageBlob();
|
||||
$image->destroy();
|
||||
|
||||
return $content;
|
||||
} catch (ImagickException $e) {
|
||||
throw LogoTraitementImpossibleException::pourErreur($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Fake image processor pour les tests.
|
||||
* Retourne un contenu PNG factice sans traitement réel.
|
||||
*/
|
||||
final class InMemoryImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
return file_get_contents($sourcePath) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
|
||||
use function count;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemoryLogoStorage implements LogoStorage
|
||||
{
|
||||
/** @var array<string, array{content: string, contentType: string}> */
|
||||
private array $files = [];
|
||||
|
||||
#[Override]
|
||||
public function store(string $content, string $key, string $contentType): string
|
||||
{
|
||||
$this->files[$key] = ['content' => $content, 'contentType' => $contentType];
|
||||
|
||||
return 'https://storage.example.com/' . $key;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(string $key): void
|
||||
{
|
||||
unset($this->files[$key]);
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset($this->files[$key]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
|
||||
use function dirname;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Stockage local des logos sur le système de fichiers.
|
||||
*
|
||||
* TODO: Remplacer par un S3LogoStorage en production.
|
||||
*/
|
||||
final readonly class LocalLogoStorage implements LogoStorage
|
||||
{
|
||||
public function __construct(
|
||||
private string $uploadDir,
|
||||
private string $publicPath,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function store(string $content, string $key, string $contentType): string
|
||||
{
|
||||
$path = $this->uploadDir . '/' . $key;
|
||||
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0o755, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, $content);
|
||||
|
||||
return $this->buildAbsoluteUrl($key);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(string $key): void
|
||||
{
|
||||
$path = $this->uploadDir . '/' . $key;
|
||||
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAbsoluteUrl(string $key): string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request !== null) {
|
||||
return $request->getSchemeAndHttpHost() . $this->publicPath . '/' . $key;
|
||||
}
|
||||
|
||||
return 'http://localhost' . $this->publicPath . '/' . $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Processeur d'image passthrough (sans redimensionnement).
|
||||
*
|
||||
* Retourne le contenu du fichier source tel quel.
|
||||
* TODO: Remplacer par GdImageProcessor quand ext-gd sera disponible.
|
||||
*/
|
||||
final class PassthroughImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
return file_get_contents($sourcePath) ?: '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user