Files
e-ticket/src/Controller/Api/ApiLiveController.php
Serreau Jovann f2f8b31d6e Reduce method returns and cognitive complexity across controllers
- AnalyticsController::track: extract handleTrackData(), reduce from 7 to 3 returns
- ApiAuthController::ssoValidate: extract ssoError/ssoSuccess helpers, reduce from 6 to 3 returns
- ApiLiveController::scan: extract findTicketFromRequest(), reduce from 4 to 3 returns
- ApiLiveController::scanForce: flatten logic, reduce from 6 to 3 returns
- ApiLiveController::processScan: extract isAlwaysValidTicket, checkRefusal,
  markScannedAndRespond, reduce cognitive complexity from 16 to under 15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:22:41 +01:00

395 lines
16 KiB
PHP

<?php
namespace App\Controller\Api;
use App\Entity\Billet;
use App\Entity\BilletBuyerItem;
use App\Entity\BilletOrder;
use App\Entity\Category;
use App\Entity\Event;
use App\Entity\User;
use App\Service\MailerService;
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;
/**
* @codeCoverageIgnore Requires DB + JWT auth integration
*/
#[Route('/api/live')]
class ApiLiveController extends AbstractController
{
use ApiAuthTrait;
private const ERR_EVENT = 'Evenement introuvable.';
private const ERR_BILLET = 'Billet introuvable.';
private const ERR_CATEGORY = 'Categorie introuvable.';
public function __construct(
#[Autowire('%kernel.secret%')] private string $appSecret,
) {
}
private function isRoot(User $user): bool
{
return \in_array('ROLE_ROOT', $user->getRoles(), true);
}
#[Route('/events', name: 'app_api_live_events', methods: ['GET'])]
public function events(Request $request, EntityManagerInterface $em): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$criteria = $this->isRoot($user) ? [] : ['account' => $user];
$events = $em->getRepository(Event::class)->findBy($criteria, ['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): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$event = $em->getRepository(Event::class)->find($id);
if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) {
return $this->error(self::ERR_EVENT, 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): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$event = $em->getRepository(Event::class)->find($id);
if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) {
return $this->error(self::ERR_EVENT, 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): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$category = $em->getRepository(Category::class)->find($id);
if (!$category || (!$this->isRoot($user) && $category->getEvent()->getAccount()->getId() !== $user->getId())) {
return $this->error(self::ERR_CATEGORY, 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): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$billet = $em->getRepository(Billet::class)->find($id);
if (!$billet || (!$this->isRoot($user) && $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId())) {
return $this->error(self::ERR_BILLET, 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): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$ticket = $this->findTicketFromRequest($request, $em);
if (!$ticket || (!$this->isRoot($user) && $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() !== $user->getId())) {
return $this->error(null === $ticket ? 'Reference ou cle de securite requise.' : self::ERR_BILLET, null === $ticket ? 400 : 404);
}
$isOwner = $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() === $user->getId();
$canForce = $this->isRoot($user) || ($isOwner && \in_array('ROLE_ORGANIZER', $user->getRoles(), true));
return $this->success($this->processScan($ticket, $em, $canForce));
}
#[Route('/scan/force', name: 'app_api_live_scan_force', methods: ['POST'])]
public function scanForce(Request $request, EntityManagerInterface $em, MailerService $mailerService): JsonResponse
{
$user = $this->authenticateRequest($request, $em, $this->appSecret);
if ($user instanceof JsonResponse) {
return $user;
}
$reference = (json_decode($request->getContent(), true) ?? [])['reference'] ?? '';
$ticket = '' !== $reference ? $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]) : null;
$event = $ticket?->getBilletBuyer()->getEvent();
$isOwner = $event && $event->getAccount()->getId() === $user->getId();
$hasAccess = $this->isRoot($user) || ($isOwner && \in_array('ROLE_ORGANIZER', $user->getRoles(), true));
if (!$ticket || !$hasAccess) {
return $this->error(!$ticket ? self::ERR_BILLET : 'Acces reserve aux organisateurs.', !$ticket ? 404 : 403);
}
return $this->success($this->executeForce($ticket, $event, $user, $em, $mailerService));
}
private function findTicketFromRequest(Request $request, EntityManagerInterface $em): ?BilletOrder
{
$data = json_decode($request->getContent(), true) ?? [];
$reference = $data['reference'] ?? '';
$securityKey = $data['securityKey'] ?? '';
if ('' !== $reference) {
return $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]);
}
if ('' !== $securityKey) {
return $em->getRepository(BilletOrder::class)->findOneBy(['securityKey' => strtoupper($securityKey)]);
}
return null;
}
/**
* @return array<string, mixed>
*/
private function executeForce(BilletOrder $ticket, Event $event, User $user, EntityManagerInterface $em, MailerService $mailerService): array
{
$previousState = $ticket->getState();
$ticket->setState(BilletOrder::STATE_VALID);
$ticket->setFirstScannedAt(new \DateTimeImmutable());
$em->flush();
$html = $this->renderView('email/scan_force_notification.html.twig', [
'event_title' => $event->getTitle(),
'billet_name' => $ticket->getBilletName(),
'reference' => $ticket->getReference(),
'buyer_name' => $ticket->getBilletBuyer()->getFirstName().' '.$ticket->getBilletBuyer()->getLastName(),
'previous_state' => $previousState,
'forced_by_name' => $user->getFirstName().' '.$user->getLastName(),
'forced_by_email' => $user->getEmail(),
]);
$mailerService->sendEmail(
$event->getAccount()->getEmail(),
'Validation forcee d\'un billet - '.$event->getTitle(),
$html,
);
return $this->buildScanResponse('accepted', 'forced', $ticket);
}
/**
* @return array<string, mixed>
*/
private function processScan(BilletOrder $ticket, EntityManagerInterface $em, bool $canForce = false): array
{
if ($this->isAlwaysValidTicket($ticket, $em)) {
return $this->markScannedAndRespond($ticket, $em, null);
}
$refusal = $this->checkRefusal($ticket, $canForce);
if (null !== $refusal) {
return $refusal;
}
$alreadyScanned = null !== $ticket->getFirstScannedAt();
$scannedToday = $alreadyScanned && $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d');
return $this->markScannedAndRespond($ticket, $em, $alreadyScanned && $scannedToday ? 'already_scanned' : null);
}
private function isAlwaysValidTicket(BilletOrder $ticket, EntityManagerInterface $em): bool
{
$billetType = $ticket->getBillet()?->getType() ?? 'billet';
if (\in_array($billetType, ['staff', 'exposant'], true)) {
return true;
}
$buyerUser = $em->getRepository(User::class)->findOneBy(['email' => $ticket->getBilletBuyer()->getEmail()]);
return $buyerUser && \in_array('ROLE_ROOT', $buyerUser->getRoles(), true);
}
/**
* @return array<string, mixed>|null
*/
private function checkRefusal(BilletOrder $ticket, bool $canForce): ?array
{
$reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired'];
if (isset($reasonMap[$ticket->getState()])) {
return $this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket) + ['canForce' => $canForce];
}
$scannedToday = null !== $ticket->getFirstScannedAt()
&& $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d');
if ($scannedToday && ($ticket->getBillet()?->hasDefinedExit() ?? false)) {
return $this->buildScanResponse('refused', 'exit_definitive', $ticket) + ['canForce' => $canForce];
}
return null;
}
/**
* @return array<string, mixed>
*/
private function markScannedAndRespond(BilletOrder $ticket, EntityManagerInterface $em, ?string $reason): array
{
if (null === $ticket->getFirstScannedAt()) {
$ticket->setFirstScannedAt(new \DateTimeImmutable());
$em->flush();
}
return $this->buildScanResponse('accepted', $reason, $ticket);
}
/**
* @return array<string, mixed>
*/
private function buildScanResponse(string $state, ?string $reason, BilletOrder $ticket): array
{
$order = $ticket->getBilletBuyer();
$items = array_map(fn (BilletBuyerItem $i) => [
'billetName' => $i->getBilletName(),
'quantity' => $i->getQuantity(),
'unitPriceHT' => $i->getUnitPriceHTDecimal(),
], $order->getItems()->toArray());
return [
'state' => $state,
'reason' => $reason,
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $order->getFirstName(),
'buyerLastName' => $order->getLastName(),
'buyerEmail' => $order->getEmail(),
'isInvitation' => (bool) $ticket->isInvitation(),
'billetType' => $ticket->getBillet()?->getType() ?? 'billet',
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'order' => [
'orderNumber' => $order->getOrderNumber(),
'status' => $order->getStatus(),
'totalHT' => $order->getTotalHTDecimal(),
'paidAt' => $order->getPaidAt()?->format(\DateTimeInterface::ATOM),
'items' => $items,
],
];
}
}