From 6fd084063f6498ffb9e44f85340dcede40ccb6f4 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Fri, 20 Feb 2026 19:35:43 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20la=20personnalisation=20du?= =?UTF-8?q?=20logo=20et=20de=20la=20couleur=20principale=20de=20l'=C3=A9ta?= =?UTF-8?q?blissement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/Dockerfile | 4 + backend/config/services.yaml | 18 + backend/migrations/Version20260220071333.php | 42 + .../Command/DeleteLogo/DeleteLogoCommand.php | 14 + .../Command/DeleteLogo/DeleteLogoHandler.php | 42 + .../UpdateBranding/UpdateBrandingCommand.php | 17 + .../UpdateBranding/UpdateBrandingHandler.php | 75 ++ .../Command/UploadLogo/UploadLogoCommand.php | 17 + .../Command/UploadLogo/UploadLogoHandler.php | 48 ++ .../Application/Port/ImageProcessor.php | 22 + .../Application/Port/LogoStorage.php | 27 + .../Application/Service/LogoUploader.php | 87 ++ .../Domain/Event/BrandingModifie.php | 38 + .../Exception/BrandColorInvalideException.php | 20 + .../ContrasteInsuffisantException.php | 21 + .../Exception/LogoFormatInvalideException.php | 25 + .../LogoTraitementImpossibleException.php | 22 + .../Exception/LogoTropGrosException.php | 21 + .../Exception/LogoUrlInvalideException.php | 20 + .../SchoolBrandingNotFoundException.php | 21 + .../Model/SchoolBranding/BrandColor.php | 64 ++ .../Model/SchoolBranding/ContrastResult.php | 19 + .../SchoolBranding/ContrastValidator.php | 92 ++ .../Domain/Model/SchoolBranding/LogoUrl.php | 47 ++ .../Model/SchoolBranding/SchoolBranding.php | 206 +++++ .../Repository/SchoolBrandingRepository.php | 21 + .../Api/Processor/DeleteLogoProcessor.php | 73 ++ .../UpdateBrandingColorsProcessor.php | 77 ++ .../Api/Processor/UploadLogoProcessor.php | 86 ++ .../Api/Provider/SchoolBrandingProvider.php | 64 ++ .../Api/Resource/SchoolBrandingResource.php | 101 +++ .../DoctrineSchoolBrandingRepository.php | 118 +++ .../InMemorySchoolBrandingRepository.php | 47 ++ .../Security/SchoolBrandingVoter.php | 99 +++ .../Storage/ImagickImageProcessor.php | 39 + .../Storage/InMemoryImageProcessor.php | 21 + .../Storage/InMemoryLogoStorage.php | 41 + .../Storage/LocalLogoStorage.php | 63 ++ .../Storage/PassthroughImageProcessor.php | 23 + .../DeleteLogo/DeleteLogoHandlerTest.php | 190 +++++ .../UpdateBrandingHandlerTest.php | 205 +++++ .../UploadLogo/UploadLogoHandlerTest.php | 213 +++++ .../Model/SchoolBranding/BrandColorTest.php | 152 ++++ .../SchoolBranding/ContrastValidatorTest.php | 149 ++++ .../Model/SchoolBranding/LogoUrlTest.php | 97 +++ .../SchoolBranding/SchoolBrandingTest.php | 325 ++++++++ frontend/e2e/branding.spec.ts | 313 +++++++ .../molecules/Pagination/Pagination.svelte | 2 +- .../SerenityScoreExplainer.svelte | 4 +- .../ChildSelector/ChildSelector.svelte | 2 +- .../organisms/Dashboard/DashboardAdmin.svelte | 5 + .../Dashboard/DashboardTeacher.svelte | 2 +- .../GuardianList/GuardianList.svelte | 8 +- .../features/branding/brandingStore.svelte.ts | 177 ++++ frontend/src/routes/+page.svelte | 4 +- frontend/src/routes/admin/+layout.svelte | 24 +- .../admin/academic-year/periods/+page.svelte | 4 +- .../src/routes/admin/assignments/+page.svelte | 4 +- .../src/routes/admin/branding/+page.svelte | 788 ++++++++++++++++++ .../src/routes/admin/classes/+page.svelte | 4 +- .../routes/admin/classes/[id]/+page.svelte | 4 +- .../routes/admin/replacements/+page.svelte | 4 +- .../src/routes/admin/subjects/+page.svelte | 4 +- .../routes/admin/subjects/[id]/+page.svelte | 4 +- frontend/src/routes/admin/users/+page.svelte | 4 +- frontend/src/routes/dashboard/+layout.svelte | 17 + frontend/src/routes/dashboard/+page.svelte | 2 +- 67 files changed, 4584 insertions(+), 29 deletions(-) create mode 100644 backend/migrations/Version20260220071333.php create mode 100644 backend/src/Administration/Application/Command/DeleteLogo/DeleteLogoCommand.php create mode 100644 backend/src/Administration/Application/Command/DeleteLogo/DeleteLogoHandler.php create mode 100644 backend/src/Administration/Application/Command/UpdateBranding/UpdateBrandingCommand.php create mode 100644 backend/src/Administration/Application/Command/UpdateBranding/UpdateBrandingHandler.php create mode 100644 backend/src/Administration/Application/Command/UploadLogo/UploadLogoCommand.php create mode 100644 backend/src/Administration/Application/Command/UploadLogo/UploadLogoHandler.php create mode 100644 backend/src/Administration/Application/Port/ImageProcessor.php create mode 100644 backend/src/Administration/Application/Port/LogoStorage.php create mode 100644 backend/src/Administration/Application/Service/LogoUploader.php create mode 100644 backend/src/Administration/Domain/Event/BrandingModifie.php create mode 100644 backend/src/Administration/Domain/Exception/BrandColorInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/ContrasteInsuffisantException.php create mode 100644 backend/src/Administration/Domain/Exception/LogoFormatInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/LogoTraitementImpossibleException.php create mode 100644 backend/src/Administration/Domain/Exception/LogoTropGrosException.php create mode 100644 backend/src/Administration/Domain/Exception/LogoUrlInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/SchoolBrandingNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/SchoolBranding/BrandColor.php create mode 100644 backend/src/Administration/Domain/Model/SchoolBranding/ContrastResult.php create mode 100644 backend/src/Administration/Domain/Model/SchoolBranding/ContrastValidator.php create mode 100644 backend/src/Administration/Domain/Model/SchoolBranding/LogoUrl.php create mode 100644 backend/src/Administration/Domain/Model/SchoolBranding/SchoolBranding.php create mode 100644 backend/src/Administration/Domain/Repository/SchoolBrandingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/DeleteLogoProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/UpdateBrandingColorsProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/UploadLogoProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/SchoolBrandingProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/SchoolBrandingResource.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolBrandingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolBrandingRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/SchoolBrandingVoter.php create mode 100644 backend/src/Administration/Infrastructure/Storage/ImagickImageProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Storage/InMemoryImageProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Storage/InMemoryLogoStorage.php create mode 100644 backend/src/Administration/Infrastructure/Storage/LocalLogoStorage.php create mode 100644 backend/src/Administration/Infrastructure/Storage/PassthroughImageProcessor.php create mode 100644 backend/tests/Unit/Administration/Application/Command/DeleteLogo/DeleteLogoHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/UpdateBranding/UpdateBrandingHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/UploadLogo/UploadLogoHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolBranding/BrandColorTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolBranding/ContrastValidatorTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolBranding/LogoUrlTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolBranding/SchoolBrandingTest.php create mode 100644 frontend/e2e/branding.spec.ts create mode 100644 frontend/src/lib/features/branding/brandingStore.svelte.ts create mode 100644 frontend/src/routes/admin/branding/+page.svelte diff --git a/backend/Dockerfile b/backend/Dockerfile index c768efa..394bbf6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,6 +14,7 @@ RUN apk add --no-cache \ gettext \ git \ icu-dev \ + imagemagick-dev \ libzip-dev \ postgresql-dev \ rabbitmq-c-dev \ @@ -23,6 +24,9 @@ RUN apk add --no-cache \ # Install PHP extensions (opcache is pre-installed in FrankenPHP) RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets +# Install Imagick extension for image processing (logo resize, etc.) +RUN pecl install imagick && docker-php-ext-enable imagick + # Install AMQP extension for RabbitMQ RUN pecl install amqp && docker-php-ext-enable amqp diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 3bfd0c7..7330fc2 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -187,6 +187,24 @@ services: arguments: $dataDirectory: '%kernel.project_dir%/var/data/calendar' + # School Branding (Story 2.13 - Personnalisation visuelle) + App\Administration\Domain\Model\SchoolBranding\ContrastValidator: + autowire: true + + App\Administration\Domain\Repository\SchoolBrandingRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository + + App\Administration\Application\Port\LogoStorage: + alias: App\Administration\Infrastructure\Storage\LocalLogoStorage + + App\Administration\Infrastructure\Storage\LocalLogoStorage: + arguments: + $uploadDir: '%kernel.project_dir%/public/uploads' + $publicPath: '/uploads' + + App\Administration\Application\Port\ImageProcessor: + alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor + # Student Guardian Repository (Story 2.7 - Liaison parents-enfants) App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: arguments: diff --git a/backend/migrations/Version20260220071333.php b/backend/migrations/Version20260220071333.php new file mode 100644 index 0000000..06a6627 --- /dev/null +++ b/backend/migrations/Version20260220071333.php @@ -0,0 +1,42 @@ +addSql(<<<'SQL' + CREATE TABLE school_branding ( + school_id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + logo_url TEXT, + logo_updated_at TIMESTAMPTZ, + primary_color VARCHAR(7), + secondary_color VARCHAR(7), + accent_color VARCHAR(7), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX idx_branding_tenant ON school_branding(tenant_id) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS school_branding'); + } +} diff --git a/backend/src/Administration/Application/Command/DeleteLogo/DeleteLogoCommand.php b/backend/src/Administration/Application/Command/DeleteLogo/DeleteLogoCommand.php new file mode 100644 index 0000000..ee6e2bf --- /dev/null +++ b/backend/src/Administration/Application/Command/DeleteLogo/DeleteLogoCommand.php @@ -0,0 +1,14 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Command/UpdateBranding/UpdateBrandingCommand.php b/backend/src/Administration/Application/Command/UpdateBranding/UpdateBrandingCommand.php new file mode 100644 index 0000000..d4fdd3a --- /dev/null +++ b/backend/src/Administration/Application/Command/UpdateBranding/UpdateBrandingCommand.php @@ -0,0 +1,17 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Command/UploadLogo/UploadLogoCommand.php b/backend/src/Administration/Application/Command/UploadLogo/UploadLogoCommand.php new file mode 100644 index 0000000..e86e91a --- /dev/null +++ b/backend/src/Administration/Application/Command/UploadLogo/UploadLogoCommand.php @@ -0,0 +1,17 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Port/ImageProcessor.php b/backend/src/Administration/Application/Port/ImageProcessor.php new file mode 100644 index 0000000..d484e93 --- /dev/null +++ b/backend/src/Administration/Application/Port/ImageProcessor.php @@ -0,0 +1,22 @@ +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); + } + } +} diff --git a/backend/src/Administration/Domain/Event/BrandingModifie.php b/backend/src/Administration/Domain/Event/BrandingModifie.php new file mode 100644 index 0000000..c725282 --- /dev/null +++ b/backend/src/Administration/Domain/Event/BrandingModifie.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->schoolId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/BrandColorInvalideException.php b/backend/src/Administration/Domain/Exception/BrandColorInvalideException.php new file mode 100644 index 0000000..353bc84 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/BrandColorInvalideException.php @@ -0,0 +1,20 @@ +getMessage()), + previous: $previous, + ); + } +} diff --git a/backend/src/Administration/Domain/Exception/LogoTropGrosException.php b/backend/src/Administration/Domain/Exception/LogoTropGrosException.php new file mode 100644 index 0000000..7a0d5ff --- /dev/null +++ b/backend/src/Administration/Domain/Exception/LogoTropGrosException.php @@ -0,0 +1,21 @@ +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; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolBranding/ContrastResult.php b/backend/src/Administration/Domain/Model/SchoolBranding/ContrastResult.php new file mode 100644 index 0000000..abb9bf7 --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolBranding/ContrastResult.php @@ -0,0 +1,19 @@ +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'); + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolBranding/LogoUrl.php b/backend/src/Administration/Domain/Model/SchoolBranding/LogoUrl.php new file mode 100644 index 0000000..1c6cfbc --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolBranding/LogoUrl.php @@ -0,0 +1,47 @@ +value = $value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolBranding/SchoolBranding.php b/backend/src/Administration/Domain/Model/SchoolBranding/SchoolBranding.php new file mode 100644 index 0000000..e530df0 --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolBranding/SchoolBranding.php @@ -0,0 +1,206 @@ +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; + } +} diff --git a/backend/src/Administration/Domain/Repository/SchoolBrandingRepository.php b/backend/src/Administration/Domain/Repository/SchoolBrandingRepository.php new file mode 100644 index 0000000..9237d21 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/SchoolBrandingRepository.php @@ -0,0 +1,21 @@ + + */ +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.'); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdateBrandingColorsProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdateBrandingColorsProcessor.php new file mode 100644 index 0000000..f832f72 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdateBrandingColorsProcessor.php @@ -0,0 +1,77 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UploadLogoProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UploadLogoProcessor.php new file mode 100644 index 0000000..c477759 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UploadLogoProcessor.php @@ -0,0 +1,86 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/SchoolBrandingProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/SchoolBrandingProvider.php new file mode 100644 index 0000000..584a146 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/SchoolBrandingProvider.php @@ -0,0 +1,64 @@ + + */ +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); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/SchoolBrandingResource.php b/backend/src/Administration/Infrastructure/Api/Resource/SchoolBrandingResource.php new file mode 100644 index 0000000..30f47cd --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/SchoolBrandingResource.php @@ -0,0 +1,101 @@ + ['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; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolBrandingRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolBrandingRepository.php new file mode 100644 index 0000000..13bda60 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolBrandingRepository.php @@ -0,0 +1,118 @@ +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 $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), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolBrandingRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolBrandingRepository.php new file mode 100644 index 0000000..3396d7b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolBrandingRepository.php @@ -0,0 +1,47 @@ + 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; + } +} diff --git a/backend/src/Administration/Infrastructure/Security/SchoolBrandingVoter.php b/backend/src/Administration/Infrastructure/Security/SchoolBrandingVoter.php new file mode 100644 index 0000000..6cb2b9c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/SchoolBrandingVoter.php @@ -0,0 +1,99 @@ + + */ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Storage/ImagickImageProcessor.php b/backend/src/Administration/Infrastructure/Storage/ImagickImageProcessor.php new file mode 100644 index 0000000..b5e52af --- /dev/null +++ b/backend/src/Administration/Infrastructure/Storage/ImagickImageProcessor.php @@ -0,0 +1,39 @@ +thumbnailImage($maxWidth, $maxHeight, true); + $image->setImageFormat('png'); + + $content = $image->getImageBlob(); + $image->destroy(); + + return $content; + } catch (ImagickException $e) { + throw LogoTraitementImpossibleException::pourErreur($e); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Storage/InMemoryImageProcessor.php b/backend/src/Administration/Infrastructure/Storage/InMemoryImageProcessor.php new file mode 100644 index 0000000..932a5cb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Storage/InMemoryImageProcessor.php @@ -0,0 +1,21 @@ + */ + 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); + } +} diff --git a/backend/src/Administration/Infrastructure/Storage/LocalLogoStorage.php b/backend/src/Administration/Infrastructure/Storage/LocalLogoStorage.php new file mode 100644 index 0000000..19d7af7 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Storage/LocalLogoStorage.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Storage/PassthroughImageProcessor.php b/backend/src/Administration/Infrastructure/Storage/PassthroughImageProcessor.php new file mode 100644 index 0000000..86c06a0 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Storage/PassthroughImageProcessor.php @@ -0,0 +1,23 @@ +brandingRepository = new InMemorySchoolBrandingRepository(); + $this->logoStorage = new InMemoryLogoStorage(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-20 10:00:00'); + } + }; + } + + #[Test] + public function itDeletesLogoFromBranding(): void + { + $this->seedBrandingWithLogo(); + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $branding = $handler($command); + + self::assertNull($branding->logoUrl); + self::assertNull($branding->logoUpdatedAt); + } + + #[Test] + public function itDeletesFileFromStorage(): void + { + $this->seedBrandingWithLogo(); + self::assertSame(1, $this->logoStorage->count()); + + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $handler($command); + + self::assertSame(0, $this->logoStorage->count()); + } + + #[Test] + public function itRecordsDomainEventOnDeletion(): void + { + $this->seedBrandingWithLogo(); + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $branding = $handler($command); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function itPersistsBrandingAfterDeletion(): void + { + $this->seedBrandingWithLogo(); + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $handler($command); + + $persisted = $this->brandingRepository->findBySchoolId( + SchoolId::fromString(self::SCHOOL_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertNotNull($persisted); + self::assertNull($persisted->logoUrl); + } + + #[Test] + public function itDoesNothingWhenNoLogoExists(): void + { + $this->seedBrandingWithoutLogo(); + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $branding = $handler($command); + + $events = $branding->pullDomainEvents(); + self::assertCount(0, $events); + } + + #[Test] + public function itThrowsWhenBrandingDoesNotExist(): void + { + $handler = $this->createHandler(); + + $command = new DeleteLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + ); + + $this->expectException(SchoolBrandingNotFoundException::class); + + $handler($command); + } + + private function createHandler(): DeleteLogoHandler + { + return new DeleteLogoHandler( + $this->brandingRepository, + new LogoUploader($this->logoStorage, new InMemoryImageProcessor()), + $this->clock, + ); + } + + private function seedBrandingWithLogo(): void + { + // Store a file first so we can verify deletion + $this->logoStorage->store('fake-content', 'logos/tenant/test.png', 'image/png'); + + $branding = SchoolBranding::creer( + schoolId: SchoolId::fromString(self::SCHOOL_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + + $branding->changerLogo( + new LogoUrl('https://storage.example.com/logos/tenant/test.png'), + new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $branding->pullDomainEvents(); + + $this->brandingRepository->save($branding); + } + + private function seedBrandingWithoutLogo(): void + { + $branding = SchoolBranding::creer( + schoolId: SchoolId::fromString(self::SCHOOL_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + + $this->brandingRepository->save($branding); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdateBranding/UpdateBrandingHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdateBranding/UpdateBrandingHandlerTest.php new file mode 100644 index 0000000..d403300 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UpdateBranding/UpdateBrandingHandlerTest.php @@ -0,0 +1,205 @@ +brandingRepository = new InMemorySchoolBrandingRepository(); + $this->contrastValidator = new ContrastValidator(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-20 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesBrandingWhenNoneExists(): void + { + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: '#1E40AF', + secondaryColor: null, + accentColor: null, + ); + + $branding = $handler($command); + + self::assertNotNull($branding->primaryColor); + self::assertSame('#1E40AF', (string) $branding->primaryColor); + self::assertNull($branding->secondaryColor); + self::assertNull($branding->accentColor); + } + + #[Test] + public function itUpdatesExistingBranding(): void + { + $this->seedBranding(); + + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: '#B91C1C', + secondaryColor: '#1E40AF', + accentColor: '#60A5FA', + ); + + $branding = $handler($command); + + self::assertNotNull($branding->primaryColor); + self::assertSame('#B91C1C', (string) $branding->primaryColor); + self::assertNotNull($branding->secondaryColor); + self::assertSame('#1E40AF', (string) $branding->secondaryColor); + self::assertNotNull($branding->accentColor); + self::assertSame('#60A5FA', (string) $branding->accentColor); + } + + #[Test] + public function itResetsColorsToNull(): void + { + $this->seedBranding(primaryColor: '#1E40AF'); + + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: null, + secondaryColor: null, + accentColor: null, + ); + + $branding = $handler($command); + + self::assertNull($branding->primaryColor); + self::assertNull($branding->secondaryColor); + self::assertNull($branding->accentColor); + } + + #[Test] + public function itRecordsDomainEvent(): void + { + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: '#1E40AF', + secondaryColor: null, + accentColor: null, + ); + + $branding = $handler($command); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function itPersistsBranding(): void + { + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: '#1E40AF', + secondaryColor: null, + accentColor: null, + ); + + $handler($command); + + $persisted = $this->brandingRepository->findBySchoolId( + SchoolId::fromString(self::SCHOOL_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertNotNull($persisted); + self::assertNotNull($persisted->primaryColor); + self::assertSame('#1E40AF', (string) $persisted->primaryColor); + } + + #[Test] + public function itRejectsColorWithInsufficientContrast(): void + { + $this->expectException(ContrasteInsuffisantException::class); + + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: '#FFFF00', + secondaryColor: null, + accentColor: null, + ); + + $handler($command); + } + + #[Test] + public function itThrowsExceptionForInvalidColor(): void + { + $this->expectException(BrandColorInvalideException::class); + + $handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock); + $command = new UpdateBrandingCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + primaryColor: 'not-a-color', + secondaryColor: null, + accentColor: null, + ); + + $handler($command); + } + + private function seedBranding(?string $primaryColor = null): void + { + $branding = SchoolBranding::creer( + schoolId: SchoolId::fromString(self::SCHOOL_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + + if ($primaryColor !== null) { + $branding->modifierCouleurs( + primaryColor: new BrandColor($primaryColor), + secondaryColor: null, + accentColor: null, + at: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $branding->pullDomainEvents(); + } + + $this->brandingRepository->save($branding); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UploadLogo/UploadLogoHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UploadLogo/UploadLogoHandlerTest.php new file mode 100644 index 0000000..0f67fd3 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UploadLogo/UploadLogoHandlerTest.php @@ -0,0 +1,213 @@ +brandingRepository = new InMemorySchoolBrandingRepository(); + $this->logoStorage = new InMemoryLogoStorage(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-20 10:00:00'); + } + }; + } + + #[Test] + public function itUploadsLogoAndCreatesBranding(): void + { + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $branding = $handler($command); + + self::assertNotNull($branding->logoUrl); + self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl); + } + + #[Test] + public function itUploadsLogoToExistingBranding(): void + { + $this->seedBranding(); + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $branding = $handler($command); + + self::assertNotNull($branding->logoUrl); + self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl); + } + + #[Test] + public function itDeletesOldFileWhenReplacingLogo(): void + { + $this->seedBranding(); + self::assertSame(1, $this->logoStorage->count()); + + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $handler($command); + + // Old file deleted, new file stored → still 1 + self::assertSame(1, $this->logoStorage->count()); + self::assertFalse($this->logoStorage->has('logos/old-tenant/old-logo.png')); + } + + #[Test] + public function itRecordsDomainEvent(): void + { + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $branding = $handler($command); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function itPersistsBrandingAfterUpload(): void + { + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $handler($command); + + $persisted = $this->brandingRepository->findBySchoolId( + SchoolId::fromString(self::SCHOOL_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertNotNull($persisted); + self::assertNotNull($persisted->logoUrl); + } + + #[Test] + public function itStoresFileInStorage(): void + { + $handler = $this->createHandler(); + $file = $this->createTestPngFile(); + + $command = new UploadLogoCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + file: $file, + ); + + $handler($command); + + self::assertSame(1, $this->logoStorage->count()); + } + + private function createHandler(): UploadLogoHandler + { + return new UploadLogoHandler( + $this->brandingRepository, + new LogoUploader($this->logoStorage, new InMemoryImageProcessor()), + $this->clock, + ); + } + + private function createTestPngFile(): UploadedFile + { + // Minimal valid PNG (1x1 pixel, transparent) + $pngData = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', true, + ); + + $tmpFile = tempnam(sys_get_temp_dir(), 'logo_test_'); + + if ($tmpFile === false) { + self::fail('Failed to create temp file'); + } + + file_put_contents($tmpFile, $pngData); + + return new UploadedFile( + $tmpFile, + 'test-logo.png', + 'image/png', + test: true, + ); + } + + private function seedBranding(): void + { + // Store an old file so we can verify it gets deleted on replacement + $this->logoStorage->store('old-content', 'logos/old-tenant/old-logo.png', 'image/png'); + + $branding = SchoolBranding::creer( + schoolId: SchoolId::fromString(self::SCHOOL_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + + $branding->changerLogo( + new LogoUrl('https://storage.example.com/logos/old-tenant/old-logo.png'), + new DateTimeImmutable('2026-02-01 10:00:00'), + ); + $branding->pullDomainEvents(); + + $this->brandingRepository->save($branding); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/BrandColorTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/BrandColorTest.php new file mode 100644 index 0000000..7499fbc --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/BrandColorTest.php @@ -0,0 +1,152 @@ +value); + } + + #[Test] + public function constructNormalizesToUppercase(): void + { + $color = new BrandColor('#3b82f6'); + + self::assertSame('#3B82F6', $color->value); + } + + #[Test] + public function constructTrimsWhitespace(): void + { + $color = new BrandColor(' #3B82F6 '); + + self::assertSame('#3B82F6', $color->value); + } + + #[Test] + #[DataProvider('validColorsProvider')] + public function constructWithValidColors(string $input, string $expected): void + { + $color = new BrandColor($input); + + self::assertSame($expected, $color->value); + } + + /** + * @return iterable + */ + public static function validColorsProvider(): iterable + { + yield 'blue' => ['#3B82F6', '#3B82F6']; + yield 'red' => ['#EF4444', '#EF4444']; + yield 'green' => ['#10B981', '#10B981']; + yield 'black' => ['#000000', '#000000']; + yield 'white' => ['#FFFFFF', '#FFFFFF']; + yield 'lowercase' => ['#aabbcc', '#AABBCC']; + } + + #[Test] + #[DataProvider('invalidColorsProvider')] + public function constructThrowsExceptionForInvalidColor(string $invalidColor): void + { + $this->expectException(BrandColorInvalideException::class); + + new BrandColor($invalidColor); + } + + /** + * @return iterable + */ + public static function invalidColorsProvider(): iterable + { + yield 'empty string' => ['']; + yield 'no hash' => ['3B82F6']; + yield 'short format' => ['#FFF']; + yield 'too short' => ['#3B82F']; + yield 'too long' => ['#3B82F6F']; + yield 'invalid characters' => ['#GGGGGG']; + yield 'rgb format' => ['rgb(59,130,246)']; + yield 'named color' => ['blue']; + } + + #[Test] + public function toRgbReturnsCorrectValues(): void + { + $color = new BrandColor('#3B82F6'); + $rgb = $color->toRgb(); + + self::assertSame(59, $rgb['r']); + self::assertSame(130, $rgb['g']); + self::assertSame(246, $rgb['b']); + } + + #[Test] + public function toRgbForBlack(): void + { + $color = new BrandColor('#000000'); + $rgb = $color->toRgb(); + + self::assertSame(0, $rgb['r']); + self::assertSame(0, $rgb['g']); + self::assertSame(0, $rgb['b']); + } + + #[Test] + public function toRgbForWhite(): void + { + $color = new BrandColor('#FFFFFF'); + $rgb = $color->toRgb(); + + self::assertSame(255, $rgb['r']); + self::assertSame(255, $rgb['g']); + self::assertSame(255, $rgb['b']); + } + + #[Test] + public function equalsReturnsTrueForSameValue(): void + { + $color1 = new BrandColor('#3B82F6'); + $color2 = new BrandColor('#3B82F6'); + + self::assertTrue($color1->equals($color2)); + } + + #[Test] + public function equalsReturnsTrueForDifferentCase(): void + { + $color1 = new BrandColor('#3B82F6'); + $color2 = new BrandColor('#3b82f6'); + + self::assertTrue($color1->equals($color2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentValue(): void + { + $color1 = new BrandColor('#3B82F6'); + $color2 = new BrandColor('#EF4444'); + + self::assertFalse($color1->equals($color2)); + } + + #[Test] + public function toStringReturnsValue(): void + { + $color = new BrandColor('#3B82F6'); + + self::assertSame('#3B82F6', (string) $color); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/ContrastValidatorTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/ContrastValidatorTest.php new file mode 100644 index 0000000..3b59d70 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/ContrastValidatorTest.php @@ -0,0 +1,149 @@ +validator = new ContrastValidator(); + } + + #[Test] + public function blackOnWhitePassesWcagAA(): void + { + $result = $this->validator->validate( + new BrandColor('#000000'), + new BrandColor('#FFFFFF'), + ); + + self::assertGreaterThanOrEqual(4.5, $result->ratio); + self::assertTrue($result->passesAA); + self::assertTrue($result->passesAALarge); + self::assertNull($result->suggestion); + } + + #[Test] + public function whiteOnBlackPassesWcagAA(): void + { + $result = $this->validator->validate( + new BrandColor('#FFFFFF'), + new BrandColor('#000000'), + ); + + self::assertGreaterThanOrEqual(4.5, $result->ratio); + self::assertTrue($result->passesAA); + self::assertTrue($result->passesAALarge); + } + + #[Test] + public function blackOnWhiteHasMaxContrast(): void + { + $result = $this->validator->validate( + new BrandColor('#000000'), + new BrandColor('#FFFFFF'), + ); + + self::assertEqualsWithDelta(21.0, $result->ratio, 0.1); + } + + #[Test] + public function sameColorHasMinContrast(): void + { + $result = $this->validator->validate( + new BrandColor('#3B82F6'), + new BrandColor('#3B82F6'), + ); + + self::assertEqualsWithDelta(1.0, $result->ratio, 0.01); + self::assertFalse($result->passesAA); + self::assertFalse($result->passesAALarge); + } + + #[Test] + public function lightGrayOnWhiteFailsWcagAA(): void + { + $result = $this->validator->validate( + new BrandColor('#CCCCCC'), + new BrandColor('#FFFFFF'), + ); + + self::assertLessThan(4.5, $result->ratio); + self::assertFalse($result->passesAA); + } + + #[Test] + public function lightGrayOnWhitePassesAALarge(): void + { + $result = $this->validator->validate( + new BrandColor('#767676'), + new BrandColor('#FFFFFF'), + ); + + self::assertGreaterThanOrEqual(3.0, $result->ratio); + self::assertTrue($result->passesAALarge); + } + + #[Test] + public function failingContrastSuggestsAlternative(): void + { + $result = $this->validator->validate( + new BrandColor('#CCCCCC'), + new BrandColor('#FFFFFF'), + ); + + self::assertFalse($result->passesAA); + self::assertNotNull($result->suggestion); + } + + #[Test] + public function passingContrastDoesNotSuggestAlternative(): void + { + $result = $this->validator->validate( + new BrandColor('#000000'), + new BrandColor('#FFFFFF'), + ); + + self::assertTrue($result->passesAA); + self::assertNull($result->suggestion); + } + + #[Test] + public function suggestedAlternativePassesWcagAA(): void + { + $result = $this->validator->validate( + new BrandColor('#CCCCCC'), + new BrandColor('#FFFFFF'), + ); + + self::assertNotNull($result->suggestion); + + $suggestionResult = $this->validator->validate( + $result->suggestion, + new BrandColor('#FFFFFF'), + ); + + self::assertTrue($suggestionResult->passesAA); + } + + #[Test] + public function blueOnWhiteCalculatesCorrectRatio(): void + { + $result = $this->validator->validate( + new BrandColor('#3B82F6'), + new BrandColor('#FFFFFF'), + ); + + self::assertGreaterThan(1.0, $result->ratio); + self::assertLessThan(21.0, $result->ratio); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/LogoUrlTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/LogoUrlTest.php new file mode 100644 index 0000000..dcc8e09 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/LogoUrlTest.php @@ -0,0 +1,97 @@ +value); + } + + #[Test] + public function constructWithValidHttpUrl(): void + { + $url = new LogoUrl('http://localhost:9000/logos/test.png'); + + self::assertSame('http://localhost:9000/logos/test.png', $url->value); + } + + #[Test] + #[DataProvider('validUrlsProvider')] + public function constructWithValidUrls(string $input): void + { + $url = new LogoUrl($input); + + self::assertSame($input, $url->value); + } + + /** + * @return iterable + */ + public static function validUrlsProvider(): iterable + { + yield 'https with path' => ['https://s3.amazonaws.com/bucket/logos/school.png']; + yield 'https with query' => ['https://cdn.example.com/logo.png?v=12345']; + yield 'http localhost' => ['http://localhost:9000/logos/test.jpg']; + yield 'https with subdomain' => ['https://storage.googleapis.com/classeo/logos/abc.png']; + } + + #[Test] + #[DataProvider('invalidUrlsProvider')] + public function constructThrowsExceptionForInvalidUrl(string $invalidUrl): void + { + $this->expectException(LogoUrlInvalideException::class); + + new LogoUrl($invalidUrl); + } + + /** + * @return iterable + */ + public static function invalidUrlsProvider(): iterable + { + yield 'empty string' => ['']; + yield 'not a url' => ['not-a-url']; + yield 'just path' => ['/logos/school.png']; + yield 'missing scheme' => ['s3.example.com/logo.png']; + yield 'ftp scheme' => ['ftp://files.example.com/logo.png']; + } + + #[Test] + public function equalsReturnsTrueForSameValue(): void + { + $url1 = new LogoUrl('https://s3.example.com/logo.png'); + $url2 = new LogoUrl('https://s3.example.com/logo.png'); + + self::assertTrue($url1->equals($url2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentValue(): void + { + $url1 = new LogoUrl('https://s3.example.com/logo1.png'); + $url2 = new LogoUrl('https://s3.example.com/logo2.png'); + + self::assertFalse($url1->equals($url2)); + } + + #[Test] + public function toStringReturnsValue(): void + { + $url = new LogoUrl('https://s3.example.com/logo.png'); + + self::assertSame('https://s3.example.com/logo.png', (string) $url); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/SchoolBrandingTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/SchoolBrandingTest.php new file mode 100644 index 0000000..0726c42 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolBranding/SchoolBrandingTest.php @@ -0,0 +1,325 @@ +creerBranding(); + + self::assertTrue($branding->schoolId->equals(SchoolId::fromString(self::SCHOOL_ID))); + self::assertTrue($branding->tenantId->equals(TenantId::fromString(self::TENANT_ID))); + self::assertNull($branding->logoUrl); + self::assertNull($branding->primaryColor); + self::assertNull($branding->secondaryColor); + self::assertNull($branding->accentColor); + } + + #[Test] + public function creerRecordsNoEvent(): void + { + $branding = $this->creerBranding(); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + #[Test] + public function modifierCouleursUpdatesPrimaryColor(): void + { + $branding = $this->creerBranding(); + $primaryColor = new BrandColor('#3B82F6'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->modifierCouleurs( + primaryColor: $primaryColor, + secondaryColor: null, + accentColor: null, + at: $now, + ); + + self::assertNotNull($branding->primaryColor); + self::assertTrue($branding->primaryColor->equals($primaryColor)); + self::assertNull($branding->secondaryColor); + self::assertNull($branding->accentColor); + } + + #[Test] + public function modifierCouleursUpdatesAllColors(): void + { + $branding = $this->creerBranding(); + $primary = new BrandColor('#3B82F6'); + $secondary = new BrandColor('#1E40AF'); + $accent = new BrandColor('#60A5FA'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->modifierCouleurs( + primaryColor: $primary, + secondaryColor: $secondary, + accentColor: $accent, + at: $now, + ); + + self::assertNotNull($branding->primaryColor); + self::assertTrue($branding->primaryColor->equals($primary)); + self::assertNotNull($branding->secondaryColor); + self::assertTrue($branding->secondaryColor->equals($secondary)); + self::assertNotNull($branding->accentColor); + self::assertTrue($branding->accentColor->equals($accent)); + } + + #[Test] + public function modifierCouleursRecordsEvent(): void + { + $branding = $this->creerBranding(); + $primaryColor = new BrandColor('#3B82F6'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->modifierCouleurs( + primaryColor: $primaryColor, + secondaryColor: null, + accentColor: null, + at: $now, + ); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + self::assertSame($now, $events[0]->occurredOn()); + self::assertTrue($branding->schoolId->value->equals($events[0]->aggregateId())); + } + + #[Test] + public function modifierCouleursDoesNotRecordEventWhenNothingChanges(): void + { + $branding = $this->creerBranding(); + $primaryColor = new BrandColor('#3B82F6'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->modifierCouleurs( + primaryColor: $primaryColor, + secondaryColor: null, + accentColor: null, + at: $now, + ); + + $branding->pullDomainEvents(); + + $branding->modifierCouleurs( + primaryColor: $primaryColor, + secondaryColor: null, + accentColor: null, + at: new DateTimeImmutable('2026-02-20 11:00:00'), + ); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + #[Test] + public function changerLogoSetsLogoUrl(): void + { + $branding = $this->creerBranding(); + $logoUrl = new LogoUrl('https://s3.example.com/logos/school.png'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->changerLogo($logoUrl, $now); + + self::assertNotNull($branding->logoUrl); + self::assertTrue($branding->logoUrl->equals($logoUrl)); + } + + #[Test] + public function changerLogoRecordsEvent(): void + { + $branding = $this->creerBranding(); + $logoUrl = new LogoUrl('https://s3.example.com/logos/school.png'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->changerLogo($logoUrl, $now); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function changerLogoDoesNotRecordEventWhenSame(): void + { + $branding = $this->creerBranding(); + $logoUrl = new LogoUrl('https://s3.example.com/logos/school.png'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->changerLogo($logoUrl, $now); + $branding->pullDomainEvents(); + + $branding->changerLogo($logoUrl, new DateTimeImmutable('2026-02-20 11:00:00')); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + #[Test] + public function supprimerLogoRemovesLogo(): void + { + $branding = $this->creerBranding(); + $logoUrl = new LogoUrl('https://s3.example.com/logos/school.png'); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->changerLogo($logoUrl, $now); + $branding->pullDomainEvents(); + + $branding->supprimerLogo(new DateTimeImmutable('2026-02-20 11:00:00')); + + self::assertNull($branding->logoUrl); + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function supprimerLogoDoesNotRecordEventWhenAlreadyNull(): void + { + $branding = $this->creerBranding(); + + $branding->supprimerLogo(new DateTimeImmutable('2026-02-20 10:00:00')); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + #[Test] + public function reinitialiserResetsAllToDefaults(): void + { + $branding = $this->creerBranding(); + $now = new DateTimeImmutable('2026-02-20 10:00:00'); + + $branding->changerLogo(new LogoUrl('https://s3.example.com/logo.png'), $now); + $branding->modifierCouleurs( + primaryColor: new BrandColor('#3B82F6'), + secondaryColor: new BrandColor('#1E40AF'), + accentColor: new BrandColor('#60A5FA'), + at: $now, + ); + + $branding->pullDomainEvents(); + + $branding->reinitialiser(new DateTimeImmutable('2026-02-20 12:00:00')); + + self::assertNull($branding->logoUrl); + self::assertNull($branding->primaryColor); + self::assertNull($branding->secondaryColor); + self::assertNull($branding->accentColor); + + $events = $branding->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(BrandingModifie::class, $events[0]); + } + + #[Test] + public function reinitialiserDoesNotRecordEventWhenAlreadyDefault(): void + { + $branding = $this->creerBranding(); + + $branding->reinitialiser(new DateTimeImmutable('2026-02-20 10:00:00')); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + #[Test] + public function estPersonnaliseReturnsFalseByDefault(): void + { + $branding = $this->creerBranding(); + + self::assertFalse($branding->estPersonnalise()); + } + + #[Test] + public function estPersonnaliseReturnsTrueWhenColorSet(): void + { + $branding = $this->creerBranding(); + $branding->modifierCouleurs( + primaryColor: new BrandColor('#3B82F6'), + secondaryColor: null, + accentColor: null, + at: new DateTimeImmutable(), + ); + + self::assertTrue($branding->estPersonnalise()); + } + + #[Test] + public function estPersonnaliseReturnsTrueWhenLogoSet(): void + { + $branding = $this->creerBranding(); + $branding->changerLogo( + new LogoUrl('https://s3.example.com/logo.png'), + new DateTimeImmutable(), + ); + + self::assertTrue($branding->estPersonnalise()); + } + + #[Test] + public function reconstituteFromStorage(): void + { + $schoolId = SchoolId::fromString(self::SCHOOL_ID); + $tenantId = TenantId::fromString(self::TENANT_ID); + $logoUrl = new LogoUrl('https://s3.example.com/logo.png'); + $primary = new BrandColor('#3B82F6'); + $secondary = new BrandColor('#1E40AF'); + $accent = new BrandColor('#60A5FA'); + $createdAt = new DateTimeImmutable('2026-02-01 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-02-15 14:30:00'); + $logoUpdatedAt = new DateTimeImmutable('2026-02-10 09:00:00'); + + $branding = SchoolBranding::reconstitute( + schoolId: $schoolId, + tenantId: $tenantId, + logoUrl: $logoUrl, + logoUpdatedAt: $logoUpdatedAt, + primaryColor: $primary, + secondaryColor: $secondary, + accentColor: $accent, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($branding->schoolId->equals($schoolId)); + self::assertTrue($branding->tenantId->equals($tenantId)); + self::assertNotNull($branding->logoUrl); + self::assertTrue($branding->logoUrl->equals($logoUrl)); + self::assertNotNull($branding->primaryColor); + self::assertTrue($branding->primaryColor->equals($primary)); + self::assertNotNull($branding->secondaryColor); + self::assertTrue($branding->secondaryColor->equals($secondary)); + self::assertNotNull($branding->accentColor); + self::assertTrue($branding->accentColor->equals($accent)); + self::assertSame($createdAt, $branding->createdAt); + self::assertSame($updatedAt, $branding->updatedAt); + + self::assertCount(0, $branding->pullDomainEvents()); + } + + private function creerBranding(): SchoolBranding + { + return SchoolBranding::creer( + schoolId: SchoolId::fromString(self::SCHOOL_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + createdAt: new DateTimeImmutable('2026-02-01 10:00:00'), + ); + } +} diff --git a/frontend/e2e/branding.spec.ts b/frontend/e2e/branding.spec.ts new file mode 100644 index 0000000..0dc3f9c --- /dev/null +++ b/frontend/e2e/branding.spec.ts @@ -0,0 +1,313 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +// Test credentials — unique to this spec to avoid cross-spec collisions +const ADMIN_EMAIL = 'e2e-branding-admin@example.com'; +const ADMIN_PASSWORD = 'BrandingAdmin123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +// Minimal valid 1x1 transparent PNG for logo upload tests +const TEST_LOGO_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 'base64' +); + +test.describe('Branding Visual Customization', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Clean up branding data from previous test runs + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + + // Clean up logo files from previous test runs + execSync( + `docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`, + { encoding: 'utf-8' } + ); + }); + + // Helper to login as admin + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + /** + * Waits for the branding page to finish loading. + * + * After hydration, the page shows the card sections (logo + colors). + * Waiting for the heading and the first .card ensures the component + * is interactive and API data has been fetched. + */ + async function waitForPageLoaded(page: import('@playwright/test').Page) { + await expect( + page.getByRole('heading', { name: /identité visuelle/i }) + ).toBeVisible({ timeout: 15000 }); + + // Wait for at least one card section to appear (loading finished) + await expect( + page.locator('.card').first() + ).toBeVisible({ timeout: 15000 }); + } + + // ============================================================================ + // [P2] Page displays logo and color sections (AC1) + // ============================================================================ + test('[P2] page affiche les sections logo et couleurs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + // Title + await expect( + page.getByRole('heading', { name: /identité visuelle/i }) + ).toBeVisible(); + + // Subtitle + await expect( + page.getByText(/personnalisez le logo et les couleurs/i) + ).toBeVisible(); + + // Logo section heading + await expect( + page.getByRole('heading', { name: /logo de l'établissement/i }) + ).toBeVisible(); + + // Format info + await expect( + page.getByText(/formats acceptés/i) + ).toBeVisible(); + + // Logo placeholder (no logo initially) + await expect( + page.getByText(/aucun logo configuré/i) + ).toBeVisible(); + + // Upload button + await expect( + page.getByText('Importer un logo') + ).toBeVisible(); + + // Color section heading + await expect( + page.getByRole('heading', { name: /couleur principale/i }) + ).toBeVisible(); + + // Color picker and text input + await expect(page.locator('#primaryColorPicker')).toBeVisible(); + await expect(page.locator('#primaryColor')).toBeVisible(); + + // Reset and save buttons + await expect( + page.getByRole('button', { name: /réinitialiser/i }) + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /enregistrer/i }) + ).toBeVisible(); + }); + + // ============================================================================ + // [P1] Changing color updates contrast indicator and preview (AC3) + // ============================================================================ + test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + const colorInput = page.locator('#primaryColor'); + + // --- Dark blue: passes AA (ratio ~10.3) → "Lisible" --- + await colorInput.fill('#1E3A5F'); + await expect(page.locator('.contrast-indicator.pass')).toBeVisible(); + await expect(page.locator('.contrast-badge')).toContainText('Lisible'); + await expect(page.locator('.preview-swatch').first()).toBeVisible(); + await expect(page.locator('.preview-swatch').first()).toHaveCSS( + 'background-color', + 'rgb(30, 58, 95)' + ); + + // --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" --- + await colorInput.fill('#FFFF00'); + await expect(page.locator('.contrast-indicator.fail')).toBeVisible(); + await expect(page.locator('.contrast-badge')).toContainText('Illisible'); + + // --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" --- + await colorInput.fill('#8B8000'); + await expect(page.locator('.contrast-indicator.warning')).toBeVisible(); + await expect(page.locator('.contrast-badge')).toContainText('Attention'); + }); + + // ============================================================================ + // [P1] Saving colors applies CSS variables immediately (AC3, AC5) + // ============================================================================ + test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + // Set a dark blue color + await page.locator('#primaryColor').fill('#1E3A5F'); + + // Click save and wait for API response + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: /enregistrer/i }).click(); + await responsePromise; + + // Success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i); + + // CSS variables applied to document root + const accentPrimary = await page.evaluate(() => + document.documentElement.style.getPropertyValue('--accent-primary') + ); + expect(accentPrimary).toBe('#1E3A5F'); + + const btnPrimaryBg = await page.evaluate(() => + document.documentElement.style.getPropertyValue('--btn-primary-bg') + ); + expect(btnPrimaryBg).toBe('#1E3A5F'); + + // Save button should be disabled (no pending changes) + await expect( + page.getByRole('button', { name: /enregistrer/i }) + ).toBeDisabled(); + }); + + // ============================================================================ + // [P2] Upload logo displays preview (AC2) + // ============================================================================ + test('[P2] upload logo affiche l\'aperçu', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + // Initially no logo + await expect(page.getByText(/aucun logo configuré/i)).toBeVisible(); + + // Trigger file chooser and upload the test PNG + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Importer un logo').click(); + const fileChooser = await fileChooserPromise; + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST' + ); + await fileChooser.setFiles({ + name: 'logo.png', + mimeType: 'image/png', + buffer: TEST_LOGO_PNG + }); + await responsePromise; + + // Success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i); + + // Logo image is now visible + await expect(page.locator('.logo-image')).toBeVisible(); + + // "Changer le logo" and "Supprimer" buttons visible + await expect(page.getByText('Changer le logo')).toBeVisible(); + await expect( + page.getByRole('button', { name: /supprimer/i }) + ).toBeVisible(); + + // Placeholder text is gone + await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible(); + }); + + // ============================================================================ + // [P2] Delete logo returns to no-logo state (AC2) + // ============================================================================ + test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + // Logo should be visible from previous test + await expect(page.locator('.logo-image')).toBeVisible(); + + // Accept the confirmation dialog, wait for DELETE response, then click + page.once('dialog', (dialog) => dialog.accept()); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE' + ); + await page.getByRole('button', { name: /supprimer/i }).click(); + await responsePromise; + + // Success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i); + + // Back to placeholder state + await expect(page.getByText(/aucun logo configuré/i)).toBeVisible(); + await expect(page.getByText('Importer un logo')).toBeVisible(); + }); + + // ============================================================================ + // [P2] Reset restores default theme (AC4) + // ============================================================================ + test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/branding`); + await waitForPageLoaded(page); + + // Color should be set from test 3 + await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F'); + + // Accept the confirmation dialog, wait for PUT response, then click + page.once('dialog', (dialog) => dialog.accept()); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: /réinitialiser/i }).click(); + await responsePromise; + + // Success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i); + + // Color input is now empty + await expect(page.locator('#primaryColor')).toHaveValue(''); + + // CSS variables removed + const accentPrimary = await page.evaluate(() => + document.documentElement.style.getPropertyValue('--accent-primary') + ); + expect(accentPrimary).toBe(''); + + // Preview swatch should not be visible (no primary color set) + await expect(page.locator('.preview-swatch')).not.toBeVisible(); + }); +}); diff --git a/frontend/src/lib/components/molecules/Pagination/Pagination.svelte b/frontend/src/lib/components/molecules/Pagination/Pagination.svelte index a2d96da..135c08c 100644 --- a/frontend/src/lib/components/molecules/Pagination/Pagination.svelte +++ b/frontend/src/lib/components/molecules/Pagination/Pagination.svelte @@ -139,7 +139,7 @@ } .pagination-page.active { - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); border-color: #3b82f6; color: white; } diff --git a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte index 58bd440..4fb4b4e 100644 --- a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte +++ b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte @@ -438,7 +438,7 @@ .btn-primary { padding: 0.75rem 1.5rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -448,6 +448,6 @@ } .btn-primary:hover { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } diff --git a/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte b/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte index 4e11aa1..8eee988 100644 --- a/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte +++ b/frontend/src/lib/components/organisms/ChildSelector/ChildSelector.svelte @@ -134,7 +134,7 @@ } .child-button.selected { - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); border-color: #3b82f6; color: white; } diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index d12c127..e28c351 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -71,6 +71,11 @@ Pédagogie Mode de notation + + 🎨 + Identité visuelle + Logo et couleurs +
📤 Importer des données diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte index 7219034..72d3763 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte @@ -269,7 +269,7 @@ .replacement-badge { display: inline-block; padding: 0.125rem 0.5rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border-radius: 9999px; font-size: 0.6875rem; diff --git a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte index 5ac0546..9dd0312 100644 --- a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte +++ b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte @@ -265,7 +265,7 @@ padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.375rem; @@ -274,7 +274,7 @@ } .btn-add:hover { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .alert { @@ -492,7 +492,7 @@ padding: 0.625rem 1.25rem; font-size: 0.875rem; font-weight: 500; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.375rem; @@ -501,7 +501,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/lib/features/branding/brandingStore.svelte.ts b/frontend/src/lib/features/branding/brandingStore.svelte.ts new file mode 100644 index 0000000..4c797f2 --- /dev/null +++ b/frontend/src/lib/features/branding/brandingStore.svelte.ts @@ -0,0 +1,177 @@ +import { browser } from '$app/environment'; +import { getApiBaseUrl } from '$lib/api/config'; +import { authenticatedFetch } from '$lib/auth/auth.svelte'; + +/** + * Store réactif pour le branding de l'établissement. + * + * Charge la configuration branding depuis l'API et injecte + * les CSS variables correspondantes dans :root. + * + * @see FR83 - Configurer logo et couleurs établissement + * @see Story 2.13 - Personnalisation visuelle établissement + */ + +export interface BrandingConfig { + schoolId: string; + logoUrl: string | null; + logoUpdatedAt: string | null; + primaryColor: string | null; + secondaryColor: string | null; + accentColor: string | null; + updatedAt: string; +} + +// State +let branding = $state(null); +let isLoading = $state(false); +let isFetched = $state(false); + +const CSS_VAR_PREFIX = '--brand'; + +/** + * Charge le branding depuis l'API. + */ +export async function fetchBranding(): Promise { + if (!browser || isFetched || isLoading) return; + + isLoading = true; + try { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/school/branding`); + + if (!response.ok) return; + + const data: BrandingConfig = await response.json(); + branding = data; + isFetched = true; + applyCssVariables(data); + } catch (error) { + console.error('[brandingStore] Failed to fetch branding:', error); + } finally { + isLoading = false; + } +} + +/** + * Met à jour le branding local après une mutation. + */ +export function updateBranding(data: BrandingConfig): void { + branding = data; + applyCssVariables(data); +} + +/** + * Retourne la configuration branding actuelle. + */ +export function getBranding(): BrandingConfig | null { + return branding; +} + +/** + * Retourne l'URL du logo (réactif via $state). + */ +export function getLogoUrl(): string | null { + return branding?.logoUrl ?? null; +} + +/** + * Retourne l'état de chargement. + */ +export function getBrandingLoading(): boolean { + return isLoading; +} + +/** + * Réinitialise l'état (à appeler au logout). + */ +export function resetBranding(): void { + branding = null; + isFetched = false; + removeCssVariables(); +} + +/** + * Injecte les CSS variables dans :root. + * + * On surcharge les design tokens existants (--accent-primary, etc.) + * pour que tous les composants (header, nav, boutons actifs) adoptent + * automatiquement les couleurs de l'établissement. + */ +function applyCssVariables(config: BrandingConfig): void { + if (!browser) return; + + const root = document.documentElement; + + if (config.primaryColor) { + root.style.setProperty('--accent-primary', config.primaryColor); + root.style.setProperty('--accent-primary-light', hexToLight(config.primaryColor)); + root.style.setProperty('--btn-primary-bg', config.primaryColor); + root.style.setProperty('--btn-primary-hover-bg', hexToDark(config.primaryColor)); + } else { + root.style.removeProperty('--accent-primary'); + root.style.removeProperty('--accent-primary-light'); + root.style.removeProperty('--btn-primary-bg'); + root.style.removeProperty('--btn-primary-hover-bg'); + } + + if (config.logoUrl) { + root.style.setProperty(`${CSS_VAR_PREFIX}-logo-url`, `url('${config.logoUrl}')`); + } else { + root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`); + } +} + +/** + * Génère une version claire d'une couleur hex (pour les fonds des éléments actifs). + * Mélange la couleur avec du blanc à 12% d'opacité. + */ +function hexToLight(hex: string): string { + const rgb = parseHex(hex); + if (!rgb) return '#e0f2fe'; + const r = Math.round(255 - (255 - rgb.r) * 0.12); + const g = Math.round(255 - (255 - rgb.g) * 0.12); + const b = Math.round(255 - (255 - rgb.b) * 0.12); + return toHex(r, g, b); +} + +/** + * Génère une version sombre d'une couleur hex (pour les états hover des boutons). + */ +function hexToDark(hex: string): string { + const rgb = parseHex(hex); + if (!rgb) return '#2563eb'; + const r = Math.round(rgb.r * 0.85); + const g = Math.round(rgb.g * 0.85); + const b = Math.round(rgb.b * 0.85); + return toHex(r, g, b); +} + +function parseHex(hex: string): { r: number; g: number; b: number } | null { + const match = hex.match(/^#([0-9A-Fa-f]{6})$/); + if (!match?.[1]) return null; + const h = match[1]; + return { + r: parseInt(h.substring(0, 2), 16), + g: parseInt(h.substring(2, 4), 16), + b: parseInt(h.substring(4, 6), 16) + }; +} + +function toHex(r: number, g: number, b: number): string { + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** + * Retire les CSS variables du branding. + */ +function removeCssVariables(): void { + if (!browser) return; + + const root = document.documentElement; + root.style.removeProperty('--accent-primary'); + root.style.removeProperty('--accent-primary-light'); + root.style.removeProperty('--btn-primary-bg'); + root.style.removeProperty('--btn-primary-hover-bg'); + root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6c36c57..8662e23 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -138,7 +138,7 @@ font-size: 1.125rem; font-weight: 600; color: white; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); border: none; border-radius: 0.5rem; cursor: pointer; @@ -147,7 +147,7 @@ } .btn-primary:hover { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); } diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index cd9cea3..3cc3ec3 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -5,12 +5,14 @@ import { logout } from '$lib/auth/auth.svelte'; import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte'; import { fetchRoles, getRoles, resetRoleContext } from '$features/roles/roleContext.svelte'; + import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte'; let { children } = $props(); let isLoggingOut = $state(false); let accessChecked = $state(false); let hasAccess = $state(false); let mobileMenuOpen = $state(false); + let logoUrl = $derived(getLogoUrl()); const ADMIN_ROLES = [ 'ROLE_SUPER_ADMIN', @@ -30,7 +32,8 @@ { href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive }, { href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive }, { href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive }, - { href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive } + { href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }, + { href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive } ]; // Load user roles and verify admin access @@ -46,12 +49,14 @@ hasAccess = true; accessChecked = true; + fetchBranding(); }); async function handleLogout() { isLoggingOut = true; try { resetRoleContext(); + resetBranding(); await logout(); } finally { isLoggingOut = false; @@ -84,6 +89,7 @@ const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar')); const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights')); const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy')); + const isBrandingActive = $derived(page.url.pathname.startsWith('/admin/branding')); const currentSectionLabel = $derived.by(() => { const path = page.url.pathname; @@ -138,6 +144,9 @@
@@ -186,6 +195,9 @@ aria-label="Menu de navigation" >
+ {#if logoUrl} + + {/if} Classeo +
+{/if} + +{#if successMessage} +
+ {successMessage} + +
+{/if} + +{#if isLoading} +
+
+

Chargement...

+
+{:else} + +
+

Logo de l'établissement

+

+ Formats acceptés : PNG, JPG. Taille maximale : 2 Mo. Le logo sera redimensionné automatiquement. +

+ +
+ {#if branding?.logoUrl} +
+ Logo de l'établissement +
+
+ + +
+ {:else} +
+ + +

Aucun logo configuré

+
+ + {/if} +
+
+ + +
+

Couleur principale

+

+ Définissez la couleur de votre établissement. Elle sera appliquée aux boutons, à la navigation + et aux éléments actifs. Laissez vide pour utiliser le thème par défaut Classeo. +

+ +
+ +
+ (primaryColor = e.currentTarget.value.toUpperCase())} + /> + { + const val = e.currentTarget.value; + primaryColor = val ? val.toUpperCase() : null; + }} + pattern="^#[0-9A-Fa-f]{6}$" + /> + {#if primaryColor} + + {/if} +
+ {#if contrastInfo} +
+ {#if contrastInfo.passesAA} + Lisible + Le texte blanc sur cette couleur est facile à lire. + {:else if contrastInfo.passesAALarge} + Attention + Le texte blanc est lisible en gros uniquement. Les petits textes seront difficiles à lire. + {:else} + Illisible + Cette couleur est trop claire : le texte blanc sur les boutons sera difficile à lire. Choisissez une couleur plus foncée. + {/if} +
+ {/if} +
+ + {#if primaryColor} +
+

Aperçu

+
+
+ Boutons +
+
+ Navigation active +
+
+
+ {/if} + +
+ + +
+
+{/if} + + diff --git a/frontend/src/routes/admin/classes/+page.svelte b/frontend/src/routes/admin/classes/+page.svelte index 92001e2..05f3747 100644 --- a/frontend/src/routes/admin/classes/+page.svelte +++ b/frontend/src/routes/admin/classes/+page.svelte @@ -423,7 +423,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -433,7 +433,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/admin/classes/[id]/+page.svelte b/frontend/src/routes/admin/classes/[id]/+page.svelte index 01d0884..d8c1a68 100644 --- a/frontend/src/routes/admin/classes/[id]/+page.svelte +++ b/frontend/src/routes/admin/classes/[id]/+page.svelte @@ -570,7 +570,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -580,7 +580,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/admin/replacements/+page.svelte b/frontend/src/routes/admin/replacements/+page.svelte index 257962a..ffd0896 100644 --- a/frontend/src/routes/admin/replacements/+page.svelte +++ b/frontend/src/routes/admin/replacements/+page.svelte @@ -710,7 +710,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -720,7 +720,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/admin/subjects/+page.svelte b/frontend/src/routes/admin/subjects/+page.svelte index cefe328..d17e208 100644 --- a/frontend/src/routes/admin/subjects/+page.svelte +++ b/frontend/src/routes/admin/subjects/+page.svelte @@ -521,7 +521,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -531,7 +531,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/admin/subjects/[id]/+page.svelte b/frontend/src/routes/admin/subjects/[id]/+page.svelte index d56a6ad..6fba0ca 100644 --- a/frontend/src/routes/admin/subjects/[id]/+page.svelte +++ b/frontend/src/routes/admin/subjects/[id]/+page.svelte @@ -480,7 +480,7 @@ .btn-primary { padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -490,7 +490,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 99b4fc6..a9c31e6 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -948,7 +948,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background: #3b82f6; + background: var(--btn-primary-bg, #3b82f6); color: white; border: none; border-radius: 0.5rem; @@ -958,7 +958,7 @@ } .btn-primary:hover:not(:disabled) { - background: #2563eb; + background: var(--btn-primary-hover-bg, #2563eb); } .btn-primary:disabled { diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 50f6413..82cddd4 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -4,9 +4,11 @@ import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte'; import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte'; import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte'; + import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte'; let { children } = $props(); let isLoggingOut = $state(false); + let logoUrl = $derived(getLogoUrl()); // Load user roles on mount for multi-role context switching (FR5) // Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode @@ -17,6 +19,7 @@ if (!refreshed) return; } fetchRoles(); + fetchBranding(); }); }); @@ -24,6 +27,7 @@ isLoggingOut = true; try { resetRoleContext(); + resetBranding(); await logout(); } finally { isLoggingOut = false; @@ -43,6 +47,9 @@