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:
53
src/Controller/Api/ApiAuthTrait.php
Normal file
53
src/Controller/Api/ApiAuthTrait.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user