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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 18:46:34 +01:00
parent a047cfa787
commit 82829f6240
11 changed files with 350 additions and 11 deletions

View File

@@ -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'

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260319143558 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@@ -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,17 +115,37 @@ 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
{
$query = $request->query->getString('q', '');
$tab = $request->query->getString('tab', 'pending');
$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 {
$allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']);
$organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true)));
}
$tab = $request->query->getString('tab', 'pending');
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(
$filtered,
@@ -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(

View File

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

View File

@@ -0,0 +1,27 @@
<?php
namespace App\EventSubscriber;
use Liip\ImagineBundle\Events\CacheResolveEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class WebpExtensionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'liip_imagine.post_resolve' => '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));
}
}

View File

@@ -8,10 +8,28 @@
<p class="font-bold text-gray-500 italic">{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Rechercher</h2>
<form method="get" action="{{ path('app_admin_organizers') }}" style="display:flex;gap:1rem;align-items:flex-end;">
<div style="flex:1;">
<input type="text" name="q" value="{{ query }}"
style="width:100%;padding:0.5rem 0.75rem;border:2px solid #111827;font-weight:700;outline:none;"
placeholder="Rechercher par nom, email, raison sociale, SIRET...">
</div>
<input type="hidden" name="tab" value="{{ tab }}">
<button type="submit" style="padding:0.5rem 1rem;border:2px solid #111827;background:#fabf04;cursor:pointer;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Rechercher</button>
{% if query %}
<a href="{{ path('app_admin_organizers', {tab: tab}) }}" style="padding:0.5rem 1rem;border:2px solid #111827;background:white;white-space:nowrap;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
{% endif %}
</form>
</div>
{% if not query %}
<div style="display:flex;gap:0;margin-bottom:2rem;">
<a href="{{ path('app_admin_organizers', {tab: 'pending'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'pending' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">En attente</a>
<a href="{{ path('app_admin_organizers', {tab: 'approved'}) }}" style="flex:1;text-align:center;padding:0.75rem;border:3px solid #111827;{{ tab == 'approved' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-sm tracking-widest transition-all">Valides</a>
</div>
{% endif %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<table style="width:100%;border-collapse:collapse;">

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\WebpExtensionSubscriber;
use Liip\ImagineBundle\Events\CacheResolveEvent;
use PHPUnit\Framework\TestCase;
class WebpExtensionSubscriberTest extends TestCase
{
private WebpExtensionSubscriber $subscriber;
protected function setUp(): void
{
$this->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());
}
}