- 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>
395 lines
16 KiB
PHP
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,
|
|
],
|
|
];
|
|
}
|
|
}
|