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,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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal file
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user