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:
2026-02-20 19:35:43 +01:00
parent cfbe96ccf8
commit 6fd084063f
67 changed files with 4584 additions and 29 deletions

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\DeleteLogo;
final readonly class DeleteLogoCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
) {
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\DeleteLogo;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DeleteLogoHandler
{
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private LogoUploader $logoUploader,
private Clock $clock,
) {
}
public function __invoke(DeleteLogoCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$branding = $this->brandingRepository->get($schoolId, $tenantId);
if ($branding->logoUrl !== null) {
$this->logoUploader->deleteByUrl($branding->logoUrl);
}
$branding->supprimerLogo($this->clock->now());
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateBranding;
final readonly class UpdateBrandingCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public ?string $primaryColor,
public ?string $secondaryColor,
public ?string $accentColor,
) {
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateBranding;
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateBrandingHandler
{
private const string WHITE = '#FFFFFF';
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private ContrastValidator $contrastValidator,
private Clock $clock,
) {
}
public function __invoke(UpdateBrandingCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$now = $this->clock->now();
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
if ($branding === null) {
$branding = SchoolBranding::creer(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $now,
);
}
$primaryColor = $command->primaryColor !== null
? new BrandColor($command->primaryColor)
: null;
if ($primaryColor !== null) {
$result = $this->contrastValidator->validate($primaryColor, new BrandColor(self::WHITE));
if (!$result->passesAA) {
throw ContrasteInsuffisantException::pourRatio($result->ratio, 4.5);
}
}
$secondaryColor = $command->secondaryColor !== null
? new BrandColor($command->secondaryColor)
: null;
$accentColor = $command->accentColor !== null
? new BrandColor($command->accentColor)
: null;
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: $secondaryColor,
accentColor: $accentColor,
at: $now,
);
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UploadLogo;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final readonly class UploadLogoCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public UploadedFile $file,
) {
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UploadLogo;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UploadLogoHandler
{
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private LogoUploader $logoUploader,
private Clock $clock,
) {
}
public function __invoke(UploadLogoCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$now = $this->clock->now();
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
if ($branding === null) {
$branding = SchoolBranding::creer(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $now,
);
}
$logoUrl = $this->logoUploader->upload($command->file, $tenantId, $branding->logoUrl);
$branding->changerLogo($logoUrl, $now);
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Port pour le traitement d'images (redimensionnement).
*/
interface ImageProcessor
{
/**
* Redimensionne une image en respectant les proportions.
*
* @param string $sourcePath Chemin vers le fichier source
* @param int $maxWidth Largeur maximale
* @param int $maxHeight Hauteur maximale
*
* @return string Contenu binaire de l'image redimensionnée (PNG)
*/
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Port pour le stockage des logos d'établissement.
*/
interface LogoStorage
{
/**
* Stocke un logo et retourne son URL publique.
*
* @param string $content Contenu binaire du fichier
* @param string $key Clé de stockage (chemin)
* @param string $contentType Type MIME du fichier
*
* @return string URL publique du fichier stocké
*/
public function store(string $content, string $key, string $contentType): string;
/**
* Supprime un fichier du stockage.
*/
public function delete(string $key): void;
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Application\Port\ImageProcessor;
use App\Administration\Application\Port\LogoStorage;
use App\Administration\Domain\Exception\LogoFormatInvalideException;
use App\Administration\Domain\Exception\LogoTropGrosException;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use App\Shared\Domain\Tenant\TenantId;
use function bin2hex;
use function in_array;
use function random_bytes;
use function strpos;
use function substr;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Service applicatif pour l'upload et le traitement des logos.
*
* Responsabilités :
* - Validation du fichier (type MIME, taille)
* - Redimensionnement via port ImageProcessor (max 200x200px)
* - Stockage via port LogoStorage
* - Suppression des anciens fichiers lors du remplacement
*/
final readonly class LogoUploader
{
private const int MAX_SIZE = 2 * 1024 * 1024; // 2 Mo
private const int MAX_DIMENSION = 200;
/** @var string[] */
private const array ALLOWED_TYPES = ['image/png', 'image/jpeg'];
private const string KEY_PREFIX = 'logos/';
public function __construct(
private LogoStorage $storage,
private ImageProcessor $imageProcessor,
) {
}
public function upload(UploadedFile $file, TenantId $tenantId, ?LogoUrl $oldLogoUrl = null): LogoUrl
{
$this->validerFichier($file);
$content = $this->imageProcessor->resize(
$file->getPathname(),
self::MAX_DIMENSION,
self::MAX_DIMENSION,
);
$key = self::KEY_PREFIX . $tenantId . '/' . bin2hex(random_bytes(8)) . '.png';
$url = $this->storage->store($content, $key, 'image/png');
if ($oldLogoUrl !== null) {
$this->deleteByUrl($oldLogoUrl);
}
return new LogoUrl($url);
}
public function deleteByUrl(LogoUrl $logoUrl): void
{
$url = $logoUrl->value;
$pos = strpos($url, self::KEY_PREFIX);
if ($pos !== false) {
$this->storage->delete(substr($url, $pos));
}
}
private function validerFichier(UploadedFile $file): void
{
$size = $file->getSize();
if ($size > self::MAX_SIZE) {
throw LogoTropGrosException::pourTaille($size, self::MAX_SIZE);
}
$mimeType = $file->getMimeType() ?? 'unknown';
if (!in_array($mimeType, self::ALLOWED_TYPES, true)) {
throw LogoFormatInvalideException::pourType($mimeType, self::ALLOWED_TYPES);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la modification du branding d'un établissement.
*/
final readonly class BrandingModifie implements DomainEvent
{
public function __construct(
public SchoolId $schoolId,
public TenantId $tenantId,
public string $champ,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->schoolId->value;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class BrandColorInvalideException extends DomainException
{
public static function pourFormat(string $value): self
{
return new self(sprintf(
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
$value,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class ContrasteInsuffisantException extends DomainException
{
public static function pourRatio(float $ratio, float $minimum): self
{
return new self(sprintf(
'Le contraste de %.1f:1 est insuffisant. Le minimum requis pour la conformité WCAG AA est de %.1f:1. Choisissez une couleur plus foncée.',
$ratio,
$minimum,
));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function implode;
use function sprintf;
final class LogoFormatInvalideException extends DomainException
{
/**
* @param string[] $allowed
*/
public static function pourType(string $mimeType, array $allowed): self
{
return new self(sprintf(
'Le format "%s" n\'est pas autorisé pour le logo. Formats acceptés : %s.',
$mimeType,
implode(', ', $allowed),
));
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
use Throwable;
final class LogoTraitementImpossibleException extends DomainException
{
public static function pourErreur(Throwable $previous): self
{
return new self(
sprintf('Impossible de traiter l\'image du logo : %s', $previous->getMessage()),
previous: $previous,
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class LogoTropGrosException extends DomainException
{
public static function pourTaille(int $taille, int $max): self
{
return new self(sprintf(
'Le fichier logo fait %d octets, la taille maximale autorisée est de %d octets (2 Mo).',
$taille,
$max,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class LogoUrlInvalideException extends DomainException
{
public static function pourUrl(string $value): self
{
return new self(sprintf(
'L\'URL du logo "%s" est invalide. Elle doit être une URL HTTP(S) valide.',
$value,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use DomainException;
use function sprintf;
final class SchoolBrandingNotFoundException extends DomainException
{
public static function pourEcole(SchoolId $schoolId): self
{
return new self(sprintf(
'Aucune personnalisation visuelle trouvée pour l\'établissement "%s".',
$schoolId,
));
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Exception\BrandColorInvalideException;
use function hexdec;
use function preg_match;
use function strtoupper;
use function substr;
use function trim;
/**
* Value Object représentant une couleur de branding (format hexadécimal).
*
* Contraintes :
* - Format #RRGGBB
* - Exemple: "#3B82F6" (bleu), "#1E40AF" (bleu foncé)
*/
final class BrandColor
{
private const string PATTERN = '/^#[0-9A-F]{6}$/';
public function __construct(
public private(set) string $value {
set(string $value) {
$normalized = strtoupper(trim($value));
if (preg_match(self::PATTERN, $normalized) !== 1) {
throw BrandColorInvalideException::pourFormat($value);
}
$this->value = $normalized;
}
},
) {
}
/**
* Convertit la couleur en composantes RGB.
*
* @return array{r: int, g: int, b: int}
*/
public function toRgb(): array
{
return [
'r' => (int) hexdec(substr($this->value, 1, 2)),
'g' => (int) hexdec(substr($this->value, 3, 2)),
'b' => (int) hexdec(substr($this->value, 5, 2)),
];
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolBranding;
/**
* Value Object représentant le résultat d'un calcul de contraste WCAG.
*/
final readonly class ContrastResult
{
public function __construct(
public float $ratio,
public bool $passesAA,
public bool $passesAALarge,
public ?BrandColor $suggestion,
) {
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolBranding;
use function max;
use function min;
use function sprintf;
/**
* Service Domain qui valide le contraste entre deux couleurs
* selon les normes WCAG AA.
*
* @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
* @see NFR-A5: Contraste WCAG AA
*/
final class ContrastValidator
{
private const float WCAG_AA_RATIO = 4.5;
private const float WCAG_AA_LARGE_RATIO = 3.0;
public function validate(BrandColor $foreground, BrandColor $background): ContrastResult
{
$ratio = $this->calculateRatio($foreground, $background);
$passesAA = $ratio >= self::WCAG_AA_RATIO;
return new ContrastResult(
ratio: $ratio,
passesAA: $passesAA,
passesAALarge: $ratio >= self::WCAG_AA_LARGE_RATIO,
suggestion: !$passesAA
? $this->suggestAlternative($foreground, $background)
: null,
);
}
private function calculateRatio(BrandColor $foreground, BrandColor $background): float
{
$l1 = $this->relativeLuminance($foreground);
$l2 = $this->relativeLuminance($background);
$lighter = max($l1, $l2);
$darker = min($l1, $l2);
return ($lighter + 0.05) / ($darker + 0.05);
}
private function relativeLuminance(BrandColor $color): float
{
$rgb = $color->toRgb();
$r = $this->linearize($rgb['r'] / 255.0);
$g = $this->linearize($rgb['g'] / 255.0);
$b = $this->linearize($rgb['b'] / 255.0);
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}
private function linearize(float $value): float
{
return $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}
/**
* Propose une couleur alternative plus sombre qui passe le contraste WCAG AA.
*/
private function suggestAlternative(BrandColor $foreground, BrandColor $background): BrandColor
{
$rgb = $foreground->toRgb();
$r = $rgb['r'];
$g = $rgb['g'];
$b = $rgb['b'];
for ($i = 0; $i < 256; ++$i) {
$darkerR = max(0, $r - $i);
$darkerG = max(0, $g - $i);
$darkerB = max(0, $b - $i);
$candidate = new BrandColor(sprintf('#%02X%02X%02X', $darkerR, $darkerG, $darkerB));
$ratio = $this->calculateRatio($candidate, $background);
if ($ratio >= self::WCAG_AA_RATIO) {
return $candidate;
}
}
return new BrandColor('#000000');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Exception\LogoUrlInvalideException;
use const FILTER_VALIDATE_URL;
use function filter_var;
use function str_starts_with;
/**
* Value Object représentant l'URL du logo d'un établissement.
*
* Contraintes :
* - URL valide
* - Schéma HTTP ou HTTPS uniquement
*/
final readonly class LogoUrl
{
public string $value;
public function __construct(string $value)
{
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
throw LogoUrlInvalideException::pourUrl($value);
}
if (!str_starts_with($value, 'http://') && !str_starts_with($value, 'https://')) {
throw LogoUrlInvalideException::pourUrl($value);
}
$this->value = $value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Event\BrandingModifie;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant la personnalisation visuelle d'un établissement.
*
* Chaque établissement a un branding unique (1:1 avec School).
* Le branding comprend un logo, une couleur principale, une couleur secondaire
* et une couleur d'accent. Toutes les personnalisations sont optionnelles :
* en l'absence de valeurs, le thème par défaut Classeo est utilisé.
*
* @see FR83: Configurer logo et couleurs établissement
*/
final class SchoolBranding extends AggregateRoot
{
public private(set) ?LogoUrl $logoUrl = null;
public private(set) ?DateTimeImmutable $logoUpdatedAt = null;
public private(set) ?BrandColor $primaryColor = null;
public private(set) ?BrandColor $secondaryColor = null;
public private(set) ?BrandColor $accentColor = null;
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) SchoolId $schoolId,
public private(set) TenantId $tenantId,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
/**
* Crée un branding vide pour un établissement (thème par défaut).
*/
public static function creer(
SchoolId $schoolId,
TenantId $tenantId,
DateTimeImmutable $createdAt,
): self {
return new self(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $createdAt,
);
}
/**
* Modifie les couleurs du branding.
*/
public function modifierCouleurs(
?BrandColor $primaryColor,
?BrandColor $secondaryColor,
?BrandColor $accentColor,
DateTimeImmutable $at,
): void {
$primarySame = $this->colorsEqual($this->primaryColor, $primaryColor);
$secondarySame = $this->colorsEqual($this->secondaryColor, $secondaryColor);
$accentSame = $this->colorsEqual($this->accentColor, $accentColor);
if ($primarySame && $secondarySame && $accentSame) {
return;
}
$this->primaryColor = $primaryColor;
$this->secondaryColor = $secondaryColor;
$this->accentColor = $accentColor;
$this->updatedAt = $at;
$this->recordEvent(new BrandingModifie(
schoolId: $this->schoolId,
tenantId: $this->tenantId,
champ: 'couleurs',
occurredOn: $at,
));
}
/**
* Change le logo de l'établissement.
*/
public function changerLogo(LogoUrl $logoUrl, DateTimeImmutable $at): void
{
if ($this->logoUrl !== null && $this->logoUrl->equals($logoUrl)) {
return;
}
$this->logoUrl = $logoUrl;
$this->logoUpdatedAt = $at;
$this->updatedAt = $at;
$this->recordEvent(new BrandingModifie(
schoolId: $this->schoolId,
tenantId: $this->tenantId,
champ: 'logo',
occurredOn: $at,
));
}
/**
* Supprime le logo de l'établissement.
*/
public function supprimerLogo(DateTimeImmutable $at): void
{
if ($this->logoUrl === null) {
return;
}
$this->logoUrl = null;
$this->logoUpdatedAt = null;
$this->updatedAt = $at;
$this->recordEvent(new BrandingModifie(
schoolId: $this->schoolId,
tenantId: $this->tenantId,
champ: 'logo',
occurredOn: $at,
));
}
/**
* Réinitialise le branding vers le thème par défaut.
*/
public function reinitialiser(DateTimeImmutable $at): void
{
if (!$this->estPersonnalise()) {
return;
}
$this->logoUrl = null;
$this->logoUpdatedAt = null;
$this->primaryColor = null;
$this->secondaryColor = null;
$this->accentColor = null;
$this->updatedAt = $at;
$this->recordEvent(new BrandingModifie(
schoolId: $this->schoolId,
tenantId: $this->tenantId,
champ: 'reinitialisation',
occurredOn: $at,
));
}
/**
* Vérifie si l'établissement a personnalisé son branding.
*/
public function estPersonnalise(): bool
{
return $this->logoUrl !== null
|| $this->primaryColor !== null
|| $this->secondaryColor !== null
|| $this->accentColor !== null;
}
/**
* Reconstitue un SchoolBranding depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
SchoolId $schoolId,
TenantId $tenantId,
?LogoUrl $logoUrl,
?DateTimeImmutable $logoUpdatedAt,
?BrandColor $primaryColor,
?BrandColor $secondaryColor,
?BrandColor $accentColor,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$branding = new self(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $createdAt,
);
$branding->logoUrl = $logoUrl;
$branding->logoUpdatedAt = $logoUpdatedAt;
$branding->primaryColor = $primaryColor;
$branding->secondaryColor = $secondaryColor;
$branding->accentColor = $accentColor;
$branding->updatedAt = $updatedAt;
return $branding;
}
private function colorsEqual(?BrandColor $a, ?BrandColor $b): bool
{
if ($a === null && $b === null) {
return true;
}
if ($a !== null && $b !== null) {
return $a->equals($b);
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
interface SchoolBrandingRepository
{
public function save(SchoolBranding $branding): void;
/**
* @throws \App\Administration\Domain\Exception\SchoolBrandingNotFoundException
*/
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding;
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding;
}

View File

@@ -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.');
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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) ?: '';
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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) ?: '';
}
}