Add admin event actions (online/offline, edit, delete) and fix Meilisearch depends_on

- Add toggle online/offline and delete routes in AdminController
- Add action buttons (En ligne, Modifier, Supprimer) in admin events template
- Bypass requireEventOwnership and requireStripeReady for ROLE_ROOT so admin can edit any event
- Add Meilisearch healthcheck and depends_on in messenger service (prod + dev)
- Add tests for all new admin routes and ROLE_ROOT bypass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 09:04:27 +01:00
parent 531c7da051
commit 23b92f101c
7 changed files with 209 additions and 1 deletions

View File

@@ -108,6 +108,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
meilisearch:
condition: service_healthy
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -134,6 +136,11 @@ services:
- meilisearch-data:/meili_data - meilisearch-data:/meili_data
networks: networks:
- e-ticket - e-ticket
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 5s
timeout: 5s
retries: 5
networks: networks:
e-ticket: e-ticket:
driver: bridge driver: bridge

View File

@@ -71,6 +71,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
meilisearch:
condition: service_healthy
bun: bun:
image: oven/bun:alpine image: oven/bun:alpine
@@ -135,6 +137,11 @@ services:
- "7700:7700" - "7700:7700"
volumes: volumes:
- meilisearch-data:/meili_data - meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
interval: 5s
timeout: 5s
retries: 5
libretranslate: libretranslate:
image: libretranslate/libretranslate:latest image: libretranslate/libretranslate:latest

View File

@@ -1189,6 +1189,9 @@ class AccountController extends AbstractController
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
if ($this->isGranted('ROLE_ROOT')) {
return $event->getAccount();
}
if ($event->getAccount()->getId() !== $user->getId()) { if ($event->getAccount()->getId() !== $user->getId()) {
throw $this->createAccessDeniedException(); throw $this->createAccessDeniedException();
} }
@@ -1209,6 +1212,10 @@ class AccountController extends AbstractController
/** @codeCoverageIgnore Tested via testOrganizerWithoutStripeBlocksEventCreation */ /** @codeCoverageIgnore Tested via testOrganizerWithoutStripeBlocksEventCreation */
private function requireStripeReady(): ?Response private function requireStripeReady(): ?Response
{ {
if ($this->isGranted('ROLE_ROOT')) {
return null;
}
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();

View File

@@ -4,9 +4,11 @@ namespace App\Controller;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\BilletBuyer; use App\Entity\BilletBuyer;
use App\Entity\Event;
use App\Entity\OrganizerInvitation; use App\Entity\OrganizerInvitation;
use App\Entity\User; use App\Entity\User;
use App\Service\AuditService; use App\Service\AuditService;
use App\Service\EventIndexService;
use App\Service\ExportService; use App\Service\ExportService;
use App\Service\MailerService; use App\Service\MailerService;
use App\Service\MeilisearchService; use App\Service\MeilisearchService;
@@ -550,7 +552,7 @@ class AdminController extends AbstractController
} }
#[Route('/evenements', name: 'app_admin_events')] #[Route('/evenements', name: 'app_admin_events')]
public function events(Request $request, PaginatorInterface $paginator, \App\Service\EventIndexService $eventIndex): Response public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
{ {
$searchQuery = $request->query->getString('q', ''); $searchQuery = $request->query->getString('q', '');
$eventsQuery = $eventIndex->searchEvents('event_admin', $searchQuery); $eventsQuery = $eventIndex->searchEvents('event_admin', $searchQuery);
@@ -563,6 +565,36 @@ class AdminController extends AbstractController
]); ]);
} }
#[Route('/evenement/{id}/en-ligne', name: 'app_admin_toggle_event_online', methods: ['POST'])]
public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
{
$event->setIsOnline(!$event->isOnline());
$em->flush();
$eventIndex->indexEvent($event);
$this->addFlash('success', $event->isOnline() ? 'Evenement mis en ligne.' : 'Evenement passe hors ligne.');
return $this->redirectToRoute('app_admin_events');
}
#[Route('/evenement/{id}/supprimer', name: 'app_admin_delete_event', methods: ['POST'])]
public function adminDeleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response
{
$eventTitle = $event->getTitle();
$eventDbId = $event->getId();
$eventIndex->removeEvent($event);
$em->remove($event);
$em->flush();
$audit->log('event_deleted', 'Event', $eventDbId, ['title' => $eventTitle]);
$this->addFlash('success', 'Evenement supprime.');
return $this->redirectToRoute('app_admin_events');
}
#[Route('/export/{year}/{month}', name: 'app_admin_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])] #[Route('/export/{year}/{month}', name: 'app_admin_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function export(int $year, int $month, ExportService $exportService): Response public function export(int $year, int $month, ExportService $exportService): Response
{ {

View File

@@ -32,6 +32,7 @@
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Lieu</th> <th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Lieu</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Statut</th> <th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Statut</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Secret</th> <th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Secret</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -66,6 +67,21 @@
<span class="text-xs text-gray-300">—</span> <span class="text-xs text-gray-300">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-right">
<div class="flex gap-2 justify-end">
<form method="post" action="{{ path('app_admin_toggle_event_online', {id: event.id}) }}">
{% if event.online %}
<button type="submit" class="admin-btn-sm-yellow text-xs font-black uppercase tracking-widest">Hors ligne</button>
{% else %}
<button type="submit" class="admin-btn-sm-white text-xs font-black uppercase tracking-widest">En ligne</button>
{% endif %}
</form>
<a href="{{ path('app_account_edit_event', {id: event.id}) }}" class="admin-btn-sm-white text-xs font-black uppercase tracking-widest">Modifier</a>
<form method="post" action="{{ path('app_admin_delete_event', {id: event.id}) }}" data-confirm="Supprimer l'evenement &laquo; {{ event.title }} &raquo; ? Cette action est irreversible.">
<button type="submit" class="admin-btn-sm-danger text-xs font-black uppercase tracking-widest">Supprimer</button>
</form>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -2202,6 +2202,30 @@ class AccountControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
} }
public function testRootCanEditEventOfAnotherUser(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
$root = $this->createUser(['ROLE_ROOT']);
$event = new \App\Entity\Event();
$event->setAccount($owner);
$event->setTitle('Owner Event Root');
$event->setStartAt(new \DateTimeImmutable('2026-08-01 10:00'));
$event->setEndAt(new \DateTimeImmutable('2026-08-01 18:00'));
$event->setAddress('1 rue');
$event->setZipcode('75001');
$event->setCity('Paris');
$em->persist($event);
$em->flush();
$client->loginUser($root);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/modifier');
self::assertResponseIsSuccessful();
}
/** /**
* @param list<string> $roles * @param list<string> $roles
*/ */

View File

@@ -2,7 +2,9 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\Event;
use App\Entity\User; use App\Entity\User;
use App\Service\EventIndexService;
use App\Service\MailerService; use App\Service\MailerService;
use App\Service\MeilisearchService; use App\Service\MeilisearchService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -862,4 +864,117 @@ class AdminControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertStringContainsString('application/pdf', $client->getResponse()->headers->get('Content-Type')); self::assertStringContainsString('application/pdf', $client->getResponse()->headers->get('Content-Type'));
} }
private function createEvent(EntityManagerInterface $em, User $organizer): Event
{
$event = new Event();
$event->setAccount($organizer);
$event->setTitle('Test Event '.uniqid());
$event->setStartAt(new \DateTimeImmutable('2026-08-01 10:00'));
$event->setEndAt(new \DateTimeImmutable('2026-08-01 18:00'));
$event->setAddress('1 rue de la Paix');
$event->setZipcode('75001');
$event->setCity('Paris');
$em->persist($event);
$em->flush();
return $event;
}
public function testToggleEventOnline(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$admin = $this->createUser(['ROLE_ROOT']);
$orga = $this->createOrganizer($em);
$event = $this->createEvent($em, $orga);
$eventIndex = $this->createMock(EventIndexService::class);
$eventIndex->expects(self::once())->method('indexEvent');
static::getContainer()->set(EventIndexService::class, $eventIndex);
self::assertFalse($event->isOnline());
$client->loginUser($admin);
$client->request('POST', '/admin/evenement/'.$event->getId().'/en-ligne');
self::assertResponseRedirects('/admin/evenements');
$em->refresh($event);
self::assertTrue($event->isOnline());
}
public function testToggleEventOffline(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$admin = $this->createUser(['ROLE_ROOT']);
$orga = $this->createOrganizer($em);
$event = $this->createEvent($em, $orga);
$event->setIsOnline(true);
$em->flush();
$eventIndex = $this->createMock(EventIndexService::class);
$eventIndex->expects(self::once())->method('indexEvent');
static::getContainer()->set(EventIndexService::class, $eventIndex);
$client->loginUser($admin);
$client->request('POST', '/admin/evenement/'.$event->getId().'/en-ligne');
self::assertResponseRedirects('/admin/evenements');
$em->refresh($event);
self::assertFalse($event->isOnline());
}
public function testDeleteEvent(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$admin = $this->createUser(['ROLE_ROOT']);
$orga = $this->createOrganizer($em);
$event = $this->createEvent($em, $orga);
$eventId = $event->getId();
$eventIndex = $this->createMock(EventIndexService::class);
$eventIndex->expects(self::once())->method('removeEvent');
static::getContainer()->set(EventIndexService::class, $eventIndex);
$client->loginUser($admin);
$client->request('POST', '/admin/evenement/'.$eventId.'/supprimer');
self::assertResponseRedirects('/admin/evenements');
$deleted = $em->getRepository(Event::class)->find($eventId);
self::assertNull($deleted);
}
public function testToggleEventOnlineDeniedForNonRoot(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser();
$orga = $this->createOrganizer($em);
$event = $this->createEvent($em, $orga);
$client->loginUser($user);
$client->request('POST', '/admin/evenement/'.$event->getId().'/en-ligne');
self::assertResponseStatusCodeSame(403);
}
public function testDeleteEventDeniedForNonRoot(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser();
$orga = $this->createOrganizer($em);
$event = $this->createEvent($em, $orga);
$client->loginUser($user);
$client->request('POST', '/admin/evenement/'.$event->getId().'/supprimer');
self::assertResponseStatusCodeSame(403);
}
} }