From 82829f624049396d6fb0c2e897aa5ef1b78269c0 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 19 Mar 2026 18:46:34 +0100 Subject: [PATCH] Add organizer logo upload, Meilisearch organizer search, and webp URL rewriting VichUploader organizer logo: - Add organizer_logo mapping with local Flysystem storage - Add logoFile, logoName, updatedAt fields to User entity - Use Vich Attribute (not deprecated Annotation) - Add migration for logo_name and updated_at columns Meilisearch organizer search: - Add search bar on /admin/organisateurs page (hides tabs during search) - Index organizers in Meilisearch on approval - Sync button on dashboard now syncs both buyers and organizers - Add tests: search query, search error Liip Imagine webp: - Add format filter to all filter_sets for explicit webp conversion - Add organizer_logo filter_set (400x400, webp) - Create WebpExtensionSubscriber to rewrite image URLs to .webp extension - 8 tests for subscriber (png, jpg, jpeg, gif, webp passthrough, case insensitive, null) Co-Authored-By: Claude Opus 4.6 (1M context) --- config/packages/flysystem.yaml | 5 ++ config/packages/liip_imagine.yaml | 11 +++ config/packages/vich_uploader.yaml | 5 ++ migrations/Version20260319143558.php | 33 ++++++++ src/Controller/AdminController.php | 68 +++++++++++++--- src/Entity/User.php | 45 +++++++++++ .../WebpExtensionSubscriber.php | 27 +++++++ templates/admin/organizers.html.twig | 18 +++++ tests/Controller/AdminControllerTest.php | 41 +++++++++- tests/Entity/UserTest.php | 27 +++++++ .../WebpExtensionSubscriberTest.php | 81 +++++++++++++++++++ 11 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 migrations/Version20260319143558.php create mode 100644 src/EventSubscriber/WebpExtensionSubscriber.php create mode 100644 tests/EventSubscriber/WebpExtensionSubscriberTest.php diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index 1445895..a689a47 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -6,3 +6,8 @@ flysystem: client: 's3_client' bucket: '%env(S3_BUCKET)%' prefix: 'uploads' + + logos.storage: + adapter: 'local' + options: + directory: '%kernel.project_dir%/public/uploads/logos' diff --git a/config/packages/liip_imagine.yaml b/config/packages/liip_imagine.yaml index ff7abdd..fe2527b 100644 --- a/config/packages/liip_imagine.yaml +++ b/config/packages/liip_imagine.yaml @@ -13,6 +13,7 @@ liip_imagine: format: webp filters: thumbnail: { size: [200, 72], mode: inset } + format: { format: 'webp' } thumbnail: quality: 80 @@ -20,15 +21,25 @@ liip_imagine: filters: thumbnail: { size: [300, 300], mode: inset } background: { size: [300, 300], position: center, color: '#ffffff' } + format: { format: 'webp' } medium: quality: 85 format: webp filters: thumbnail: { size: [600, 600], mode: inset } + format: { format: 'webp' } large: quality: 90 format: webp filters: thumbnail: { size: [1200, 1200], mode: inset } + format: { format: 'webp' } + + organizer_logo: + quality: 85 + format: webp + filters: + thumbnail: { size: [400, 400], mode: inset } + format: { format: 'webp' } diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 1fc19df..84111fd 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -13,3 +13,8 @@ vich_uploader: service: Vich\UploaderBundle\Naming\CurrentDateDirectoryNamer options: date_time_format: 'Y/m' + + organizer_logo: + uri_prefix: /uploads/logos + upload_destination: logos.storage + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/migrations/Version20260319143558.php b/migrations/Version20260319143558.php new file mode 100644 index 0000000..1edbe7b --- /dev/null +++ b/migrations/Version20260319143558.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE "user" ADD logo_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP logo_name'); + $this->addSql('ALTER TABLE "user" DROP updated_at'); + } +} diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index fc056f5..e0c9124 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -46,7 +46,25 @@ class AdminController extends AbstractController $meilisearch->addDocuments('buyers', $documents); } - $this->addFlash('success', sprintf('%d acheteur(s) synchronise(s) dans Meilisearch.', \count($documents))); + $organizers = array_filter($allUsers, fn (User $u) => $u->isApproved() && \in_array('ROLE_ORGANIZER', $u->getRoles(), true)); + + $meilisearch->createIndexIfNotExists('organizers'); + + $orgaDocs = array_map(fn (User $u) => [ + 'id' => $u->getId(), + 'firstName' => $u->getFirstName(), + 'lastName' => $u->getLastName(), + 'email' => $u->getEmail(), + 'companyName' => $u->getCompanyName(), + 'siret' => $u->getSiret(), + 'city' => $u->getCity(), + ], array_values($organizers)); + + if ([] !== $orgaDocs) { + $meilisearch->addDocuments('organizers', $orgaDocs); + } + + $this->addFlash('success', sprintf('%d acheteur(s) et %d organisateur(s) synchronise(s) dans Meilisearch.', \count($documents), \count($orgaDocs))); return $this->redirectToRoute('app_admin_dashboard'); } @@ -97,16 +115,36 @@ class AdminController extends AbstractController } #[Route('/organisateurs', name: 'app_admin_organizers')] - public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response + public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response { - $allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']); - $organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true))); - + $query = $request->query->getString('q', ''); $tab = $request->query->getString('tab', 'pending'); - if ('approved' === $tab) { - $filtered = array_values(array_filter($organizers, fn (User $u) => $u->isApproved())); + $searchResults = null; + + if ('' !== $query) { + try { + $searchResults = $meilisearch->search('organizers', $query, ['limit' => 50]); + } catch (\Throwable) { + $this->addFlash('error', 'Erreur de recherche Meilisearch.'); + } + } + + if (null !== $searchResults && isset($searchResults['hits'])) { + $hitIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits']); + $organizers = $em->getRepository(User::class)->findBy(['id' => $hitIds]); } else { - $filtered = array_values(array_filter($organizers, fn (User $u) => !$u->isApproved())); + $allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']); + $organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true))); + } + + if ('' === $query) { + if ('approved' === $tab) { + $filtered = array_values(array_filter($organizers, fn (User $u) => $u->isApproved())); + } else { + $filtered = array_values(array_filter($organizers, fn (User $u) => !$u->isApproved())); + } + } else { + $filtered = $organizers; } $pagination = $paginator->paginate( @@ -118,6 +156,7 @@ class AdminController extends AbstractController return $this->render('admin/organizers.html.twig', [ 'organizers' => $pagination, 'tab' => $tab, + 'query' => $query, ]); } @@ -251,11 +290,22 @@ class AdminController extends AbstractController } #[Route('/organisateur/{id}/approuver', name: 'app_admin_approve_organizer', methods: ['POST'])] - public function approveOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService): Response + public function approveOrganizer(User $user, EntityManagerInterface $em, MailerService $mailerService, MeilisearchService $meilisearch): Response { $user->setIsApproved(true); $em->flush(); + $meilisearch->createIndexIfNotExists('organizers'); + $meilisearch->addDocuments('organizers', [[ + 'id' => $user->getId(), + 'firstName' => $user->getFirstName(), + 'lastName' => $user->getLastName(), + 'email' => $user->getEmail(), + 'companyName' => $user->getCompanyName(), + 'siret' => $user->getSiret(), + 'city' => $user->getCity(), + ]]); + $loginUrl = $this->generateUrl('app_login', [], UrlGeneratorInterface::ABSOLUTE_URL); $mailerService->sendEmail( diff --git a/src/Entity/User.php b/src/Entity/User.php index 22d4033..3756092 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,12 +5,15 @@ namespace App\Entity; use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Vich\UploaderBundle\Mapping\Attribute as Vich; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[UniqueEntity(fields: ['email'], message: 'Un compte existe déjà avec cet email.')] +#[Vich\Uploadable] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] @@ -55,6 +58,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 20, nullable: true)] private ?string $phone = null; + #[Vich\UploadableField(mapping: 'organizer_logo', fileNameProperty: 'logoName')] + private ?File $logoFile = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $logoName = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + #[ORM\Column(length: 6, nullable: true)] private ?string $resetCode = null; @@ -236,6 +248,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getLogoFile(): ?File + { + return $this->logoFile; + } + + public function setLogoFile(?File $logoFile = null): static + { + $this->logoFile = $logoFile; + + if (null !== $logoFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + + return $this; + } + + public function getLogoName(): ?string + { + return $this->logoName; + } + + public function setLogoName(?string $logoName): static + { + $this->logoName = $logoName; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + public function getResetCode(): ?string { return $this->resetCode; diff --git a/src/EventSubscriber/WebpExtensionSubscriber.php b/src/EventSubscriber/WebpExtensionSubscriber.php new file mode 100644 index 0000000..5545859 --- /dev/null +++ b/src/EventSubscriber/WebpExtensionSubscriber.php @@ -0,0 +1,27 @@ + 'onPostResolve', + ]; + } + + public function onPostResolve(CacheResolveEvent $event): void + { + $url = $event->getUrl(); + + if (null === $url) { + return; + } + + $event->setUrl(preg_replace('/\.(png|jpg|jpeg|gif|bmp|tiff)$/i', '.webp', $url)); + } +} diff --git a/templates/admin/organizers.html.twig b/templates/admin/organizers.html.twig index e1b131e..c371d34 100644 --- a/templates/admin/organizers.html.twig +++ b/templates/admin/organizers.html.twig @@ -8,10 +8,28 @@

{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.

+
+

Rechercher

+
+
+ +
+ + + {% if query %} + Effacer + {% endif %} +
+
+ +{% if not query %}
En attente Valides
+{% endif %}
diff --git a/tests/Controller/AdminControllerTest.php b/tests/Controller/AdminControllerTest.php index 2711f40..610c633 100644 --- a/tests/Controller/AdminControllerTest.php +++ b/tests/Controller/AdminControllerTest.php @@ -107,8 +107,7 @@ class AdminControllerTest extends WebTestCase $em->flush(); $meilisearch = $this->createMock(MeilisearchService::class); - $meilisearch->expects(self::once())->method('createIndexIfNotExists'); - $meilisearch->expects(self::once())->method('addDocuments'); + $meilisearch->expects(self::exactly(2))->method('createIndexIfNotExists'); static::getContainer()->set(MeilisearchService::class, $meilisearch); $client->loginUser($admin); @@ -329,6 +328,39 @@ class AdminControllerTest extends WebTestCase self::assertNull($buyer->getEmailVerificationToken()); } + public function testOrganizersSearchWithQuery(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects(self::once())->method('search')->with('organizers')->willReturn([ + 'hits' => [], + 'estimatedTotalHits' => 0, + ]); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('GET', '/admin/organisateurs?q=test'); + + self::assertResponseIsSuccessful(); + } + + public function testOrganizersSearchWithError(): void + { + $client = static::createClient(); + $admin = $this->createUser(['ROLE_ROOT']); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->method('search')->willThrowException(new \RuntimeException('Meilisearch down')); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + + $client->loginUser($admin); + $client->request('GET', '/admin/organisateurs?q=test'); + + self::assertResponseIsSuccessful(); + } + public function testOrganizersPagePendingTab(): void { $client = static::createClient(); @@ -363,6 +395,11 @@ class AdminControllerTest extends WebTestCase $mailer->expects(self::once())->method('sendEmail'); static::getContainer()->set(MailerService::class, $mailer); + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects(self::once())->method('createIndexIfNotExists')->with('organizers'); + $meilisearch->expects(self::once())->method('addDocuments'); + static::getContainer()->set(MeilisearchService::class, $meilisearch); + $client->loginUser($admin); $client->request('POST', '/admin/organisateur/'.$orga->getId().'/approuver'); diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index 6a7785e..7f22625 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -88,6 +88,33 @@ class UserTest extends TestCase self::assertNull($user->getPhone()); } + public function testLogoFields(): void + { + $user = new User(); + + self::assertNull($user->getLogoFile()); + self::assertNull($user->getLogoName()); + self::assertNull($user->getUpdatedAt()); + + $result = $user->setLogoName('logo.png'); + self::assertSame($user, $result); + self::assertSame('logo.png', $user->getLogoName()); + + $file = new \Symfony\Component\HttpFoundation\File\File(__FILE__); + $result = $user->setLogoFile($file); + self::assertSame($user, $result); + self::assertSame($file, $user->getLogoFile()); + self::assertNotNull($user->getUpdatedAt()); + } + + public function testSetLogoFileNullDoesNotUpdateTimestamp(): void + { + $user = new User(); + $user->setLogoFile(null); + + self::assertNull($user->getUpdatedAt()); + } + public function testResetCodeFields(): void { $user = new User(); diff --git a/tests/EventSubscriber/WebpExtensionSubscriberTest.php b/tests/EventSubscriber/WebpExtensionSubscriberTest.php new file mode 100644 index 0000000..522d37d --- /dev/null +++ b/tests/EventSubscriber/WebpExtensionSubscriberTest.php @@ -0,0 +1,81 @@ +subscriber = new WebpExtensionSubscriber(); + } + + public function testGetSubscribedEvents(): void + { + $events = WebpExtensionSubscriber::getSubscribedEvents(); + + self::assertArrayHasKey('liip_imagine.post_resolve', $events); + self::assertSame('onPostResolve', $events['liip_imagine.post_resolve']); + } + + public function testRewritesPngToWebp(): void + { + $event = new CacheResolveEvent('logo.png', 'thumbnail', '/media/cache/thumbnail/logo.png'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl()); + } + + public function testRewritesJpgToWebp(): void + { + $event = new CacheResolveEvent('photo.jpg', 'medium', '/media/cache/medium/photo.jpg'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/medium/photo.webp', $event->getUrl()); + } + + public function testRewritesJpegToWebp(): void + { + $event = new CacheResolveEvent('image.jpeg', 'large', '/media/cache/large/image.jpeg'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/large/image.webp', $event->getUrl()); + } + + public function testRewritesGifToWebp(): void + { + $event = new CacheResolveEvent('anim.gif', 'thumbnail', '/media/cache/thumbnail/anim.gif'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/thumbnail/anim.webp', $event->getUrl()); + } + + public function testDoesNotRewriteWebp(): void + { + $event = new CacheResolveEvent('already.webp', 'thumbnail', '/media/cache/thumbnail/already.webp'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/thumbnail/already.webp', $event->getUrl()); + } + + public function testCaseInsensitive(): void + { + $event = new CacheResolveEvent('logo.PNG', 'thumbnail', '/media/cache/thumbnail/logo.PNG'); + $this->subscriber->onPostResolve($event); + + self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl()); + } + + public function testNullUrlIsIgnored(): void + { + $event = new CacheResolveEvent('logo.png', 'thumbnail'); + $this->subscriber->onPostResolve($event); + + self::assertNull($event->getUrl()); + } +}