Implement all API routes for sandbox and live controllers

ApiAuthTrait:
- authenticateRequest(): verify JWT from headers, return User or JsonResponse error
- success()/error(): standard JSON response helpers

ApiSandboxController (/api/sandbox):
- GET /events: returns fixture events
- GET /events/{id}: returns single fixture event
- GET /events/{id}/categories: returns fixture categories by event
- GET /categories/{id}/billets: returns fixture billets by category
- GET /billets/{id}: returns fixture billet detail
- POST /scan: returns fixture scan result by reference
- All routes authenticated via JWT, data from data/sandbox/fixtures.json

ApiLiveController (/api/live):
- GET /events: real events from DB, filtered by authenticated organizer
- GET /events/{id}: real event detail with ownership check
- GET /events/{id}/categories: real categories with isActive computed
- GET /categories/{id}/billets: real billets with sold count from BilletOrder
- GET /billets/{id}: full billet detail with image URL, category, event
- POST /scan: real ticket scan with state machine:
  - invalid → refused (reason: invalid)
  - expired → refused (reason: expired)
  - already scanned + hasDefinedExit → refused (reason: exit_definitive)
  - valid → accepted (sets firstScannedAt if first scan)
  - unlimited entry/exit if !hasDefinedExit
- All routes check event/billet ownership against authenticated user
- Image URLs use request hostname (dynamic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 19:54:33 +01:00
parent 8b66cbd334
commit f9a76c5775
3 changed files with 448 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Api;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
trait ApiAuthTrait
{
private function authenticateRequest(Request $request, EntityManagerInterface $em, string $appSecret): User|JsonResponse
{
$email = $request->headers->get('ETicket-Email', '');
$jwt = $request->headers->get('ETicket-JWT', '');
if ('' === $email || '' === $jwt) {
return new JsonResponse(['success' => false, 'data' => null, 'error' => 'Headers ETicket-Email et ETicket-JWT requis.'], 401);
}
$result = ApiAuthController::verifyJwt($jwt, $email, $appSecret);
if (null === $result['userId']) {
return new JsonResponse(['success' => false, 'data' => null, 'error' => 'Token invalide.'], 401);
}
if ($result['expired']) {
return new JsonResponse(['success' => false, 'data' => null, 'error' => 'Token expire. Utilisez POST /api/auth/refresh pour le renouveler.'], 401);
}
$user = $em->getRepository(User::class)->find($result['userId']);
if (!$user || $user->getEmail() !== $email) {
return new JsonResponse(['success' => false, 'data' => null, 'error' => 'Utilisateur introuvable.'], 401);
}
return $user;
}
private function success(mixed $data, array $meta = []): JsonResponse
{
$response = ['success' => true, 'data' => $data, 'error' => null];
if ([] !== $meta) {
$response['meta'] = $meta;
}
return new JsonResponse($response);
}
private function error(string $message, int $status = 400): JsonResponse
{
return new JsonResponse(['success' => false, 'data' => null, 'error' => $message], $status);
}
}

View File

@@ -2,10 +2,277 @@
namespace App\Controller\Api;
use App\Entity\Billet;
use App\Entity\BilletOrder;
use App\Entity\Category;
use App\Entity\Event;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/live')]
class ApiLiveController extends AbstractController
{
use ApiAuthTrait;
#[Route('/events', name: 'app_api_live_events', methods: ['GET'])]
public function events(Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$events = $em->getRepository(Event::class)->findBy(['account' => $user], ['startAt' => 'DESC']);
$data = array_map(fn (Event $e) => [
'id' => $e->getId(),
'title' => $e->getTitle(),
'startAt' => $e->getStartAt()?->format(\DateTimeInterface::ATOM),
'endAt' => $e->getEndAt()?->format(\DateTimeInterface::ATOM),
'address' => $e->getAddress(),
'zipcode' => $e->getZipcode(),
'city' => $e->getCity(),
'isOnline' => $e->isOnline(),
'isSecret' => $e->isSecret(),
'imageUrl' => $e->getEventMainPictureName() ? $request->getSchemeAndHttpHost().'/uploads/events/'.$e->getEventMainPictureName() : null,
], $events);
return $this->success($data);
}
#[Route('/events/{id}', name: 'app_api_live_event', requirements: ['id' => '\d+'], methods: ['GET'])]
public function event(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$event = $em->getRepository(Event::class)->find($id);
if (!$event || $event->getAccount()->getId() !== $user->getId()) {
return $this->error('Evenement introuvable.', 404);
}
return $this->success([
'id' => $event->getId(),
'title' => $event->getTitle(),
'description' => $event->getDescription(),
'startAt' => $event->getStartAt()?->format(\DateTimeInterface::ATOM),
'endAt' => $event->getEndAt()?->format(\DateTimeInterface::ATOM),
'address' => $event->getAddress(),
'zipcode' => $event->getZipcode(),
'city' => $event->getCity(),
'isOnline' => $event->isOnline(),
'isSecret' => $event->isSecret(),
'imageUrl' => $event->getEventMainPictureName() ? $request->getSchemeAndHttpHost().'/uploads/events/'.$event->getEventMainPictureName() : null,
]);
}
#[Route('/events/{id}/categories', name: 'app_api_live_categories', requirements: ['id' => '\d+'], methods: ['GET'])]
public function categories(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$event = $em->getRepository(Event::class)->find($id);
if (!$event || $event->getAccount()->getId() !== $user->getId()) {
return $this->error('Evenement introuvable.', 404);
}
$categories = $em->getRepository(Category::class)->findBy(['event' => $event], ['position' => 'ASC']);
$data = array_map(fn (Category $c) => [
'id' => $c->getId(),
'name' => $c->getName(),
'position' => $c->getPosition(),
'startAt' => $c->getStartAt()->format(\DateTimeInterface::ATOM),
'endAt' => $c->getEndAt()->format(\DateTimeInterface::ATOM),
'isHidden' => $c->isHidden(),
'isActive' => $c->isActive(),
], $categories);
return $this->success($data);
}
#[Route('/categories/{id}/billets', name: 'app_api_live_billets', requirements: ['id' => '\d+'], methods: ['GET'])]
public function billets(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$category = $em->getRepository(Category::class)->find($id);
if (!$category || $category->getEvent()->getAccount()->getId() !== $user->getId()) {
return $this->error('Categorie introuvable.', 404);
}
$billets = $em->getRepository(Billet::class)->findBy(['category' => $category], ['position' => 'ASC']);
$soldCounts = [];
$billetIds = array_map(fn (Billet $b) => $b->getId(), $billets);
if ($billetIds) {
$rows = $em->createQueryBuilder()
->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt')
->from(BilletOrder::class, 'bo')
->where('bo.billet IN (:ids)')
->setParameter('ids', $billetIds)
->groupBy('bo.billet')
->getQuery()
->getArrayResult();
foreach ($rows as $row) {
$soldCounts[$row['billetId']] = (int) $row['cnt'];
}
}
$data = array_map(fn (Billet $b) => [
'id' => $b->getId(),
'name' => $b->getName(),
'priceHT' => $b->getPriceHT(),
'quantity' => $b->getQuantity(),
'sold' => $soldCounts[$b->getId()] ?? 0,
'type' => $b->getType(),
'isGeneratedBillet' => $b->isGeneratedBillet(),
'notBuyable' => $b->isNotBuyable(),
'position' => $b->getPosition(),
], $billets);
return $this->success($data);
}
#[Route('/billets/{id}', name: 'app_api_live_billet', requirements: ['id' => '\d+'], methods: ['GET'])]
public function billet(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$billet = $em->getRepository(Billet::class)->find($id);
if (!$billet || $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId()) {
return $this->error('Billet introuvable.', 404);
}
$sold = $em->createQueryBuilder()
->select('COUNT(bo.id)')
->from(BilletOrder::class, 'bo')
->where('bo.billet = :billet')
->setParameter('billet', $billet)
->getQuery()
->getSingleScalarResult();
return $this->success([
'id' => $billet->getId(),
'name' => $billet->getName(),
'description' => $billet->getDescription(),
'priceHT' => $billet->getPriceHT(),
'quantity' => $billet->getQuantity(),
'sold' => (int) $sold,
'type' => $billet->getType(),
'isGeneratedBillet' => $billet->isGeneratedBillet(),
'hasDefinedExit' => $billet->hasDefinedExit(),
'notBuyable' => $billet->isNotBuyable(),
'position' => $billet->getPosition(),
'imageUrl' => $billet->getPictureName() ? $request->getSchemeAndHttpHost().'/uploads/billets/'.$billet->getPictureName() : null,
'category' => ['id' => $billet->getCategory()->getId(), 'name' => $billet->getCategory()->getName()],
'event' => ['id' => $billet->getCategory()->getEvent()->getId(), 'title' => $billet->getCategory()->getEvent()->getTitle()],
]);
}
#[Route('/scan', name: 'app_api_live_scan', methods: ['POST'])]
public function scan(Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$data = json_decode($request->getContent(), true);
$reference = $data['reference'] ?? '';
if ('' === $reference) {
return $this->error('Reference requise.', 400);
}
$ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]);
if (!$ticket) {
return $this->error('Billet introuvable.', 404);
}
$event = $ticket->getBilletBuyer()->getEvent();
if ($event->getAccount()->getId() !== $user->getId()) {
return $this->error('Billet introuvable.', 404);
}
if (BilletOrder::STATE_INVALID === $ticket->getState()) {
return $this->success([
'state' => 'refused',
'reason' => 'invalid',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'details' => null,
]);
}
if (BilletOrder::STATE_EXPIRED === $ticket->getState()) {
return $this->success([
'state' => 'refused',
'reason' => 'expired',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'details' => null,
]);
}
$hasDefinedExit = $ticket->getBillet()?->hasDefinedExit() ?? false;
if (null !== $ticket->getFirstScannedAt() && $hasDefinedExit) {
return $this->success([
'state' => 'refused',
'reason' => 'exit_definitive',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => true,
'details' => null,
]);
}
if (null === $ticket->getFirstScannedAt()) {
$ticket->setFirstScannedAt(new \DateTimeImmutable());
$em->flush();
}
return $this->success([
'state' => 'accepted',
'reason' => null,
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $hasDefinedExit,
'details' => [],
]);
}
}

View File

@@ -2,10 +2,138 @@
namespace App\Controller\Api;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/sandbox')]
class ApiSandboxController extends AbstractController
{
use ApiAuthTrait;
/** @var array<string, mixed>|null */
private ?array $fixtures = null;
public function __construct(
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {
}
#[Route('/events', name: 'app_api_sandbox_events', methods: ['GET'])]
public function events(Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$fixtures = $this->loadFixtures();
return $this->success($fixtures['events'] ?? []);
}
#[Route('/events/{id}', name: 'app_api_sandbox_event', requirements: ['id' => '\d+'], methods: ['GET'])]
public function event(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$fixtures = $this->loadFixtures();
foreach ($fixtures['events'] ?? [] as $event) {
if ($event['id'] === $id) {
return $this->success($event);
}
}
return $this->error('Evenement introuvable.', 404);
}
#[Route('/events/{id}/categories', name: 'app_api_sandbox_categories', requirements: ['id' => '\d+'], methods: ['GET'])]
public function categories(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$fixtures = $this->loadFixtures();
return $this->success($fixtures['categories'][(string) $id] ?? []);
}
#[Route('/categories/{id}/billets', name: 'app_api_sandbox_billets', requirements: ['id' => '\d+'], methods: ['GET'])]
public function billets(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$fixtures = $this->loadFixtures();
return $this->success($fixtures['billets'][(string) $id] ?? []);
}
#[Route('/billets/{id}', name: 'app_api_sandbox_billet', requirements: ['id' => '\d+'], methods: ['GET'])]
public function billet(int $id, Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$fixtures = $this->loadFixtures();
$detail = $fixtures['billetDetails'][(string) $id] ?? null;
if (!$detail) {
return $this->error('Billet introuvable.', 404);
}
return $this->success($detail);
}
#[Route('/scan', name: 'app_api_sandbox_scan', methods: ['POST'])]
public function scan(Request $request, EntityManagerInterface $em, #[Autowire('%kernel.secret%')] string $appSecret): JsonResponse
{
$auth = $this->authenticateRequest($request, $em, $appSecret);
if ($auth instanceof JsonResponse) {
return $auth;
}
$data = json_decode($request->getContent(), true);
$reference = $data['reference'] ?? '';
if ('' === $reference) {
return $this->error('Reference requise.', 400);
}
$fixtures = $this->loadFixtures();
$result = $fixtures['scan'][$reference] ?? null;
if (!$result) {
return $this->error('Billet introuvable.', 404);
}
unset($result['_comment']);
return $this->success($result);
}
/**
* @return array<string, mixed>
*/
private function loadFixtures(): array
{
if (null === $this->fixtures) {
$path = $this->projectDir.'/data/sandbox/fixtures.json';
$this->fixtures = json_decode((string) file_get_contents($path), true) ?? [];
}
return $this->fixtures;
}
}