Files
e-ticket/src/Controller/AdminController.php
Serreau Jovann 58e139e261 Apply PHP CS Fixer code style fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:19:15 +01:00

983 lines
39 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\AnalyticsEvent;
use App\Entity\AnalyticsUniqId;
use App\Entity\AuditLog;
use App\Entity\BilletBuyer;
use App\Entity\BilletOrder;
use App\Entity\Event;
use App\Entity\OrganizerInvitation;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\BilletOrderService;
use App\Service\EventIndexService;
use App\Service\ExportService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\SiretService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/admin')]
#[IsGranted('ROLE_ROOT')]
class AdminController extends AbstractController
{
private const DQL_STATUS_PAID = 'o.status = :paid';
#[Route('', name: 'app_admin_dashboard')]
public function dashboard(EntityManagerInterface $em, #[Autowire(service: 'stats.cache')] CacheInterface $cache): Response
{
$stats = $cache->get('admin_dashboard_stats', function (ItemInterface $item) use ($em) {
$item->expiresAfter(600);
$allUsers = $em->getRepository(User::class)->findAll();
$organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved());
$totalCA = (int) ($em->createQueryBuilder()
->select('SUM(o.totalHT)')
->from(BilletBuyer::class, 'o')
->where(self::DQL_STATUS_PAID)
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult() ?? 0);
$nbOrders = $em->createQueryBuilder()
->select('COUNT(o.id)')
->from(BilletBuyer::class, 'o')
->where(self::DQL_STATUS_PAID)
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult();
$nbBillets = $em->getRepository(BilletOrder::class)->count([]);
$commissionEticket = 0;
$commissionStripe = 0;
$paidOrders = $em->createQueryBuilder()
->select('o', 'e')
->from(BilletBuyer::class, 'o')
->join('o.event', 'e')
->join('e.account', 'a')
->where(self::DQL_STATUS_PAID)
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getResult();
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
foreach ($paidOrders as $order) {
$rate = $order->getEvent()->getAccount()->getCommissionRate() ?? 3;
$ht = $order->getTotalHT() / 100;
$commissionEticket += $ht * ($rate / 100);
$commissionStripe += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
}
return [
'nbOrgas' => \count($organizers),
'nbOrders' => $nbOrders,
'nbBillets' => $nbBillets,
'totalCA' => $totalCA / 100,
'commissionEticket' => $commissionEticket,
'commissionStripe' => $commissionStripe,
];
});
return $this->render('admin/dashboard.html.twig', $stats);
}
/** @codeCoverageIgnore Requires live Meilisearch */
#[Route('/sync-meilisearch', name: 'app_admin_sync_meilisearch', methods: ['POST'])]
public function syncMeilisearch(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
$allUsers = $em->getRepository(User::class)->findAll();
$buyers = array_filter($allUsers, fn (User $u) => $u->isVerified() && !\in_array('ROLE_ORGANIZER', $u->getRoles(), true) && !\in_array('ROLE_ROOT', $u->getRoles(), true));
$meilisearch->createIndexIfNotExists('buyers');
$documents = array_map(fn (User $u) => [
'id' => $u->getId(),
'firstName' => $u->getFirstName(),
'lastName' => $u->getLastName(),
'email' => $u->getEmail(),
'createdAt' => $u->getCreatedAt()->format('d/m/Y'),
], array_values($buyers));
if ([] !== $documents) {
$meilisearch->addDocuments('buyers', $documents);
}
$organizers = array_filter($allUsers, fn (User $u) => $u->isApproved() && \in_array('ROLE_ORGANIZER', $u->getRoles(), true));
$meilisearch->createIndexIfNotExists('organizers');
$orgaDocs = array_map(fn (User $u) => [
'id' => $u->getId(),
'firstName' => $u->getFirstName(),
'lastName' => $u->getLastName(),
'email' => $u->getEmail(),
'companyName' => $u->getCompanyName(),
'siret' => $u->getSiret(),
'city' => $u->getCity(),
], array_values($organizers));
if ([] !== $orgaDocs) {
$meilisearch->addDocuments('organizers', $orgaDocs);
}
$this->addFlash('success', sprintf('%d acheteur(s) et %d organisateur(s) synchronise(s) dans Meilisearch.', \count($documents), \count($orgaDocs)));
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/utilisateurs', name: 'app_admin_users')]
public function users(EntityManagerInterface $em): Response
{
$users = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/users.html.twig', [
'users' => $users,
]);
}
#[Route('/acheteurs', name: 'app_admin_buyers')]
public function buyers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response
{
$query = $request->query->getString('q', '');
$searchResults = null;
if ('' !== $query) {
try {
$searchResults = $meilisearch->search('buyers', $query, ['limit' => 50]);
} catch (\Throwable) {
$this->addFlash('error', 'Erreur de recherche Meilisearch.');
}
}
if (null !== $searchResults && isset($searchResults['hits'])) {
$hitIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits']);
$allUsers = $em->getRepository(User::class)->findBy(['id' => $hitIds]);
$buyers = $allUsers;
} else {
$allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']);
$buyers = array_values(array_filter($allUsers, fn (User $u) => !\in_array('ROLE_ORGANIZER', $u->getRoles(), true) && !\in_array('ROLE_ROOT', $u->getRoles(), true)));
}
$pagination = $paginator->paginate(
$buyers,
$request->query->getInt('page', 1),
10,
);
return $this->render('admin/buyers.html.twig', [
'buyers' => $pagination,
'query' => $query,
]);
}
#[Route('/organisateurs', name: 'app_admin_organizers')]
public function organizers(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response
{
$query = $request->query->getString('q', '');
$tab = $request->query->getString('tab', 'pending');
$searchResults = null;
if ('' !== $query) {
try {
$searchResults = $meilisearch->search('organizers', $query, ['limit' => 50]);
} catch (\Throwable) {
$this->addFlash('error', 'Erreur de recherche Meilisearch.');
}
}
if (null !== $searchResults && isset($searchResults['hits'])) {
$hitIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits']);
$organizers = $em->getRepository(User::class)->findBy(['id' => $hitIds]);
} else {
$allUsers = $em->getRepository(User::class)->findBy([], ['createdAt' => 'DESC']);
$organizers = array_values(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true)));
}
if ('' === $query) {
if ('approved' === $tab) {
$filtered = array_values(array_filter($organizers, fn (User $u) => $u->isApproved()));
} else {
$filtered = array_values(array_filter($organizers, fn (User $u) => !$u->isApproved()));
}
} else {
$filtered = $organizers;
}
$pagination = $paginator->paginate(
$filtered,
$request->query->getInt('page', 1),
10,
);
return $this->render('admin/organizers.html.twig', [
'organizers' => $pagination,
'tab' => $tab,
'query' => $query,
]);
}
#[Route('/acheteurs/creer', name: 'app_admin_create_buyer', methods: ['POST'])]
public function createBuyer(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $passwordHasher,
ValidatorInterface $validator,
MailerService $mailerService,
): Response {
$user = new User();
$user->setFirstName(trim($request->request->getString('first_name')));
$user->setLastName(trim($request->request->getString('last_name')));
$user->setEmail(trim($request->request->getString('email')));
$user->setPassword($passwordHasher->hashPassword($user, bin2hex(random_bytes(16))));
$token = bin2hex(random_bytes(32));
$user->setEmailVerificationToken($token);
$errors = $validator->validate($user);
if (0 === count($errors)) {
$em->persist($user);
$em->flush();
$verificationUrl = $this->generateUrl('app_verify_email', [
'token' => $token,
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailerService->sendEmail(
to: $user->getEmail(),
subject: 'Verifiez votre adresse email - E-Ticket',
content: $this->renderView('email/verification.html.twig', [
'firstName' => $user->getFirstName(),
'verificationUrl' => $verificationUrl,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Acheteur %s %s cree.', $user->getFirstName(), $user->getLastName()));
return $this->redirectToRoute('app_admin_buyers');
}
foreach ($errors as $error) {
$this->addFlash('error', $error->getMessage());
}
return $this->redirectToRoute('app_admin_buyers');
}
#[Route('/acheteur/{id}/renvoyer-verification', name: 'app_admin_resend_verification', methods: ['POST'])]
public function resendVerification(User $user, EntityManagerInterface $em, MailerService $mailerService): Response
{
$token = bin2hex(random_bytes(32));
$user->setEmailVerificationToken($token);
$em->flush();
$verificationUrl = $this->generateUrl('app_verify_email', [
'token' => $token,
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailerService->sendEmail(
to: $user->getEmail(),
subject: 'Verifiez votre adresse email - E-Ticket',
content: $this->renderView('email/verification.html.twig', [
'firstName' => $user->getFirstName(),
'verificationUrl' => $verificationUrl,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Email de verification renvoye a %s.', $user->getEmail()));
return $this->redirectToRoute('app_admin_buyers');
}
#[Route('/acheteur/{id}/forcer-verification', name: 'app_admin_force_verification', methods: ['POST'])]
public function forceVerification(User $user, EntityManagerInterface $em): Response
{
$user->setIsVerified(true);
$user->setEmailVerifiedAt(new \DateTimeImmutable());
$user->setEmailVerificationToken(null);
$em->flush();
$this->addFlash('success', sprintf('Email de %s %s force comme verifie.', $user->getFirstName(), $user->getLastName()));
return $this->redirectToRoute('app_admin_buyers');
}
#[Route('/acheteur/{id}/reset-password', name: 'app_admin_reset_password', methods: ['POST'])]
public function resetPassword(User $user, MailerService $mailerService): Response
{
$resetUrl = $this->generateUrl('app_forgot_password', [], UrlGeneratorInterface::ABSOLUTE_URL);
$mailerService->sendEmail(
to: $user->getEmail(),
subject: 'Reinitialisation de votre mot de passe - E-Ticket',
content: $this->renderView('email/admin_reset_password.html.twig', [
'firstName' => $user->getFirstName(),
'email' => $user->getEmail(),
'resetUrl' => $resetUrl,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Lien de reinitialisation envoye a %s.', $user->getEmail()));
return $this->redirectToRoute('app_admin_buyers');
}
#[Route('/acheteur/{id}/supprimer', name: 'app_admin_delete_buyer', methods: ['POST'])]
public function deleteBuyer(User $user, EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
$name = sprintf('%s %s', $user->getFirstName(), $user->getLastName());
$userId = $user->getId();
$em->remove($user);
$em->flush();
try {
$meilisearch->deleteDocument('buyers', $userId);
} catch (\Throwable) {
// Meilisearch failure should not block user deletion
}
$this->addFlash('success', sprintf('Compte de %s supprime.', $name));
return $this->redirectToRoute('app_admin_buyers');
}
#[Route('/organisateur/{id}/approuver', name: 'app_admin_approve_organizer', methods: ['POST'])]
public function approveOrganizer(User $user, Request $request, EntityManagerInterface $em, MailerService $mailerService, MeilisearchService $meilisearch): Response
{
$offer = $request->request->getString('offer', 'free');
$commissionRate = (float) $request->request->getString('commission_rate', '3');
$billingAmount = (int) $request->request->getString('billing_amount', '1000');
$user->setIsApproved(true);
$user->setOffer($offer);
$user->setCommissionRate($commissionRate);
$user->setIsBilling(true);
$user->setBillingAmount($billingAmount);
$user->setBillingState('good');
$em->flush();
$meilisearch->createIndexIfNotExists('organizers');
$meilisearch->addDocuments('organizers', [[
'id' => $user->getId(),
'firstName' => $user->getFirstName(),
'lastName' => $user->getLastName(),
'email' => $user->getEmail(),
'companyName' => $user->getCompanyName(),
'siret' => $user->getSiret(),
'city' => $user->getCity(),
]]);
$loginUrl = $this->generateUrl('app_login', [], UrlGeneratorInterface::ABSOLUTE_URL);
$mailerService->sendEmail(
to: $user->getEmail(),
subject: 'Votre compte organisateur a ete approuve - E-Ticket',
content: $this->renderView('email/organizer_approved.html.twig', [
'firstName' => $user->getFirstName(),
'loginUrl' => $loginUrl,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Organisateur %s %s approuve.', $user->getFirstName(), $user->getLastName()));
return $this->redirectToRoute('app_admin_organizers');
}
#[Route('/organisateur/{id}/refuser', name: 'app_admin_reject_organizer', methods: ['POST'])]
public function rejectOrganizer(User $user, Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
{
$reason = trim($request->request->getString('reason'));
$email = $user->getEmail();
$firstName = $user->getFirstName();
$lastName = $user->getLastName();
$em->remove($user);
$em->flush();
$mailerService->sendEmail(
to: $email,
subject: 'Votre demande de compte organisateur a ete refusee - E-Ticket',
content: $this->renderView('email/organizer_rejected.html.twig', [
'firstName' => $firstName,
'reason' => $reason,
]),
withUnsubscribe: false,
);
$this->addFlash('success', sprintf('Demande de %s %s refusee.', $firstName, $lastName));
return $this->redirectToRoute('app_admin_organizers');
}
#[Route('/organisateur/{id}/siret', name: 'app_admin_siret_check')]
public function siretCheck(User $user, SiretService $siretService): Response
{
if ($user->isApproved()) {
return $this->redirectToRoute('app_admin_organizers');
}
$siret = $user->getSiret();
if (!$siret) {
$this->addFlash('error', 'Aucun SIRET renseigne.');
return $this->redirectToRoute('app_admin_organizers');
}
$data = $siretService->lookup($siret);
$rna = $data['complements']['identifiant_association'] ?? null;
$rnaData = $rna ? $siretService->lookupRna($rna) : null;
return $this->render('admin/siret_check.html.twig', [
'user' => $user,
'siret' => $siret,
'data' => $data,
'rnaData' => $rnaData,
]);
}
#[Route('/organisateur/{id}/siret/refresh', name: 'app_admin_siret_refresh', methods: ['POST'])]
public function siretRefresh(User $user, SiretService $siretService): Response
{
$siret = $user->getSiret();
if ($siret) {
$data = $siretService->lookup($siret);
$rna = $data['complements']['identifiant_association'] ?? null;
$siretService->clearCache($siret, $rna);
$this->addFlash('success', 'Cache SIRET vide. Les donnees ont ete rechargees.');
}
return $this->redirectToRoute('app_admin_siret_check', ['id' => $user->getId()]);
}
#[Route('/organisateur/{id}/modifier', name: 'app_admin_edit_organizer', methods: ['GET', 'POST'])]
public function editOrganizer(User $user, Request $request, EntityManagerInterface $em): Response
{
if (!$user->isApproved()) {
return $this->redirectToRoute('app_admin_organizers');
}
if ($request->isMethod('POST')) {
$user->setFirstName(trim($request->request->getString('first_name')));
$user->setLastName(trim($request->request->getString('last_name')));
$user->setEmail(trim($request->request->getString('email')));
$user->setPhone(trim($request->request->getString('phone')));
$user->setCompanyName(trim($request->request->getString('company_name')));
$user->setSiret(trim($request->request->getString('siret')));
$user->setAddress(trim($request->request->getString('address')));
$user->setPostalCode(trim($request->request->getString('postal_code')));
$user->setCity(trim($request->request->getString('city')));
$user->setOffer($request->request->getString('offer'));
$user->setCommissionRate((float) $request->request->getString('commission_rate'));
$logoFile = $request->files->get('logo');
if ($logoFile) {
$user->setLogoFile($logoFile);
}
$em->flush();
$this->addFlash('success', sprintf('Organisateur %s %s mis a jour.', $user->getFirstName(), $user->getLastName()));
return $this->redirectToRoute('app_admin_organizers', ['tab' => 'approved']);
}
return $this->render('admin/edit_organizer.html.twig', [
'user' => $user,
]);
}
#[Route('/organisateur/{id}/suspendre', name: 'app_admin_suspend_organizer', methods: ['POST'])]
public function suspendOrganizer(User $user, EntityManagerInterface $em, AuditService $audit): Response
{
$suspended = !$user->isSuspended();
$user->setIsSuspended($suspended ?: null);
$em->flush();
$audit->log($suspended ? 'organizer_suspended' : 'organizer_reactivated', 'User', $user->getId(), [
'email' => $user->getEmail(),
'companyName' => $user->getCompanyName(),
]);
$this->addFlash('success', $suspended
? 'Organisateur '.$user->getCompanyName().' suspendu.'
: 'Organisateur '.$user->getCompanyName().' reactive.');
return $this->redirectToRoute('app_admin_organizers', ['tab' => 'approved']);
}
#[Route('/commandes', name: 'app_admin_orders', methods: ['GET'])]
public function orders(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
{
$status = $request->query->getString('status', '');
$search = $request->query->getString('q', '');
$qb = $em->createQueryBuilder()
->select('o', 'i')
->from(BilletBuyer::class, 'o')
->leftJoin('o.items', 'i')
->orderBy('o.createdAt', 'DESC');
if ('' !== $status) {
$qb->andWhere('o.status = :status')->setParameter('status', $status);
}
if ('' !== $search) {
$qb->andWhere('o.orderNumber LIKE :q OR o.firstName LIKE :q OR o.lastName LIKE :q OR o.email LIKE :q')
->setParameter('q', '%'.$search.'%');
}
$orders = $paginator->paginate($qb->getQuery(), $request->query->getInt('page', 1), 20);
$totalCA = $em->createQueryBuilder()
->select('SUM(o.totalHT)')
->from(BilletBuyer::class, 'o')
->where(self::DQL_STATUS_PAID)
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult() ?? 0;
$totalOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
$totalRefunded = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_REFUNDED]);
$totalCancelled = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_CANCELLED]);
$totalPending = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PENDING]);
return $this->render('admin/orders.html.twig', [
'orders' => $orders,
'status' => $status,
'search' => $search,
'totalCA' => (int) $totalCA / 100,
'totalOrders' => $totalOrders,
'totalRefunded' => $totalRefunded,
'totalCancelled' => $totalCancelled,
'totalPending' => $totalPending,
]);
}
#[Route('/commandes/{id}/billets', name: 'app_admin_order_tickets', requirements: ['id' => '\d+'], methods: ['GET'])]
public function orderTickets(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
{
$order = $em->getRepository(BilletBuyer::class)->find($id);
if (!$order) {
throw $this->createNotFoundException();
}
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
if (!$tickets) {
throw $this->createNotFoundException('Aucun billet pour cette commande.');
}
if (1 === \count($tickets)) {
$pdf = $billetOrderService->generatePdf($tickets[0]);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$tickets[0]->getReference().'.pdf"',
]);
}
$zip = new \ZipArchive();
$tmpFile = tempnam(sys_get_temp_dir(), 'tickets_');
$zip->open($tmpFile, \ZipArchive::OVERWRITE);
foreach ($tickets as $ticket) {
$pdf = $billetOrderService->generatePdf($ticket);
$zip->addFromString($ticket->getReference().'.pdf', $pdf);
}
$zip->close();
$content = file_get_contents($tmpFile);
unlink($tmpFile);
return new Response($content, 200, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="billets_'.$order->getOrderNumber().'.zip"',
]);
}
#[Route('/evenements', name: 'app_admin_events')]
public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
{
$searchQuery = $request->query->getString('q', '');
$eventsQuery = $eventIndex->searchEvents('event_admin', $searchQuery);
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
return $this->render('admin/events.html.twig', [
'events' => $events,
'searchQuery' => $searchQuery,
]);
}
#[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'])]
public function export(int $year, int $month, ExportService $exportService): Response
{
$stats = $exportService->getMonthlyStats($year, $month);
$csv = $exportService->generateCsv($stats['orders'], true);
$filename = sprintf('export_admin_%04d_%02d.csv', $year, $month);
return new Response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
#[Route('/export/{year}/{month}/pdf', name: 'app_admin_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function exportPdf(int $year, int $month, ExportService $exportService): Response
{
$stats = $exportService->getMonthlyStats($year, $month);
$pdf = $exportService->generatePdf($stats, $year, $month);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="recap_admin_'.sprintf('%04d_%02d', $year, $month).'.pdf"',
]);
}
#[Route('/logs', name: 'app_admin_logs', methods: ['GET'])]
public function logs(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
{
$logs = $em->getRepository(AuditLog::class)->findBy([], ['createdAt' => 'DESC']);
$paginatedLogs = $paginator->paginate($logs, $request->query->getInt('page', 1), 30);
return $this->render('admin/logs.html.twig', [
'logs' => $paginatedLogs,
]);
}
#[Route('/infra', name: 'app_admin_infra', methods: ['GET'])]
public function infra(#[Autowire('%kernel.project_dir%')] string $projectDir): Response
{
$path = $projectDir.'/var/infra.json';
if (!file_exists($path)) {
return $this->render('admin/infra.html.twig', [
'snapshot_missing' => true,
'server' => [], 'containers' => [], 'redis_global' => ['connected' => false],
'redis_dbs' => [], 'postgres' => ['connected' => false], 'pgbouncer' => ['connected' => false],
'generated_at' => null,
]);
}
$data = json_decode(file_get_contents($path), true) ?? [];
return $this->render('admin/infra.html.twig', $data);
}
#[Route('/analytics', name: 'app_admin_analytics', methods: ['GET'])]
public function analytics(Request $request, EntityManagerInterface $em): Response
{
$period = $request->query->getString('period', '7d');
$since = match ($period) {
'today' => new \DateTimeImmutable('today'),
'30d' => new \DateTimeImmutable('-30 days'),
'all' => new \DateTimeImmutable('2020-01-01'),
default => new \DateTimeImmutable('-7 days'),
};
$visitors = (int) $em->createQueryBuilder()
->select('COUNT(v.id)')
->from(AnalyticsUniqId::class, 'v')
->where('v.createdAt >= :since')
->setParameter('since', $since)
->getQuery()
->getSingleScalarResult();
$pageviews = (int) $em->createQueryBuilder()
->select('COUNT(e.id)')
->from(AnalyticsEvent::class, 'e')
->where('e.createdAt >= :since')
->setParameter('since', $since)
->getQuery()
->getSingleScalarResult();
// Bounce rate: visitors with only 1 pageview
$bouncedVisitors = (int) $em->createQueryBuilder()
->select('COUNT(v.id)')
->from(AnalyticsUniqId::class, 'v')
->where('v.createdAt >= :since')
->andWhere('(SELECT COUNT(e2.id) FROM '.AnalyticsEvent::class.' e2 WHERE e2.visitor = v.id) = 1')
->setParameter('since', $since)
->getQuery()
->getSingleScalarResult();
$bounceRate = $visitors > 0 ? round($bouncedVisitors / $visitors * 100, 1) : 0;
$topPages = $em->createQueryBuilder()
->select('e.url, COUNT(e.id) AS hits')
->from(AnalyticsEvent::class, 'e')
->where('e.createdAt >= :since')
->setParameter('since', $since)
->groupBy('e.url')
->orderBy('hits', 'DESC')
->setMaxResults(20)
->getQuery()
->getArrayResult();
$topReferrers = $em->createQueryBuilder()
->select('e.referrer, COUNT(e.id) AS hits')
->from(AnalyticsEvent::class, 'e')
->where('e.createdAt >= :since')
->andWhere('e.referrer IS NOT NULL')
->andWhere("e.referrer != ''")
->andWhere('e.referrer NOT LIKE :self1')
->andWhere('e.referrer NOT LIKE :self2')
->setParameter('self1', '%ticket.e-cosplay.fr%')
->setParameter('self2', '%esyweb.local%')
->setParameter('since', $since)
->groupBy('e.referrer')
->orderBy('hits', 'DESC')
->setMaxResults(10)
->getQuery()
->getArrayResult();
$devices = $em->createQueryBuilder()
->select('v.deviceType, COUNT(v.id) AS cnt')
->from(AnalyticsUniqId::class, 'v')
->where('v.createdAt >= :since')
->setParameter('since', $since)
->groupBy('v.deviceType')
->getQuery()
->getArrayResult();
$browsers = $em->createQueryBuilder()
->select('v.browser, COUNT(v.id) AS cnt')
->from(AnalyticsUniqId::class, 'v')
->where('v.createdAt >= :since')
->andWhere('v.browser IS NOT NULL')
->setParameter('since', $since)
->groupBy('v.browser')
->orderBy('cnt', 'DESC')
->setMaxResults(10)
->getQuery()
->getArrayResult();
$osList = $em->createQueryBuilder()
->select('v.os, COUNT(v.id) AS cnt')
->from(AnalyticsUniqId::class, 'v')
->where('v.createdAt >= :since')
->andWhere('v.os IS NOT NULL')
->setParameter('since', $since)
->groupBy('v.os')
->orderBy('cnt', 'DESC')
->getQuery()
->getArrayResult();
// Daily chart data
$conn = $em->getConnection();
$visitorsPerDay = $conn->executeQuery(
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_uniq_id WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
['since' => $since->format('Y-m-d H:i:s')],
)->fetchAllAssociative();
$pageviewsPerDay = $conn->executeQuery(
'SELECT CAST(created_at AS DATE) AS day, COUNT(*) AS cnt FROM analytics_event WHERE created_at >= :since GROUP BY day ORDER BY day ASC',
['since' => $since->format('Y-m-d H:i:s')],
)->fetchAllAssociative();
// Merge into aligned arrays
$allDays = [];
foreach ($visitorsPerDay as $r) {
$allDays[$r['day']] = true;
}
foreach ($pageviewsPerDay as $r) {
$allDays[$r['day']] = true;
}
ksort($allDays);
$visitorsMap = [];
foreach ($visitorsPerDay as $r) {
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
$visitorsMap[$d] = (int) $r['cnt'];
}
$pageviewsMap = [];
foreach ($pageviewsPerDay as $r) {
$d = $r['day'] instanceof \DateTimeInterface ? $r['day']->format('Y-m-d') : (string) $r['day'];
$pageviewsMap[$d] = (int) $r['cnt'];
}
$chartLabels = array_keys($allDays);
$chartVisitors = array_map(fn ($d) => (int) ($visitorsMap[$d] ?? 0), $chartLabels);
$chartPageviews = array_map(fn ($d) => (int) ($pageviewsMap[$d] ?? 0), $chartLabels);
return $this->render('admin/analytics.html.twig', [
'period' => $period,
'visitors' => $visitors,
'pageviews' => $pageviews,
'bounce_rate' => $bounceRate,
'top_pages' => $topPages,
'top_referrers' => $topReferrers,
'devices' => $devices,
'browsers' => $browsers,
'os_list' => $osList,
'chart_labels' => $chartLabels,
'chart_visitors' => $chartVisitors,
'chart_pageviews' => $chartPageviews,
]);
}
#[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])]
public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response
{
$invitations = $em->getRepository(OrganizerInvitation::class)->findBy([], ['createdAt' => 'DESC']);
if ($request->isMethod('POST')) {
$companyName = trim($request->request->getString('company_name'));
$firstName = trim($request->request->getString('first_name'));
$lastName = trim($request->request->getString('last_name'));
$email = trim($request->request->getString('email'));
$message = trim($request->request->getString('message')) ?: null;
$offer = $request->request->getString('offer', 'free');
$commissionRate = (float) $request->request->getString('commission_rate', '3');
$billingAmount = (int) $request->request->getString('billing_amount', '1000');
if ('' === $companyName || '' === $firstName || '' === $lastName || '' === $email) {
$this->addFlash('error', 'Tous les champs obligatoires doivent etre remplis.');
return $this->redirectToRoute('app_admin_invite_organizer');
}
$invitation = new OrganizerInvitation();
$invitation->setCompanyName($companyName);
$invitation->setFirstName($firstName);
$invitation->setLastName($lastName);
$invitation->setEmail($email);
$invitation->setMessage($message);
$invitation->setOffer($offer);
$invitation->setCommissionRate($commissionRate);
$invitation->setBillingAmount($billingAmount);
$em->persist($invitation);
$em->flush();
$viewUrl = $this->generateUrl('app_invitation_view', [
'token' => $invitation->getToken(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $this->renderView('email/organizer_invitation.html.twig', [
'invitation' => $invitation,
'viewUrl' => $viewUrl,
]);
$mailerService->sendEmail(
$email,
'Invitation organisateur - E-Ticket',
$html,
);
$this->addFlash('success', 'Invitation envoyee a '.$email.'.');
return $this->redirectToRoute('app_admin_invite_organizer');
}
return $this->render('admin/invite_organizer.html.twig', [
'invitations' => $invitations,
]);
}
#[Route('/organisateurs/invitation/{id}/supprimer', name: 'app_admin_delete_invitation', methods: ['POST'])]
public function deleteInvitation(int $id, EntityManagerInterface $em): Response
{
$invitation = $em->getRepository(OrganizerInvitation::class)->find($id);
if (!$invitation) {
throw $this->createNotFoundException();
}
$em->remove($invitation);
$em->flush();
$this->addFlash('success', 'Invitation supprimee.');
return $this->redirectToRoute('app_admin_invite_organizer');
}
#[Route('/organisateurs/invitation/{id}/renvoyer', name: 'app_admin_resend_invitation', methods: ['POST'])]
public function resendInvitation(int $id, EntityManagerInterface $em, MailerService $mailerService): Response
{
$invitation = $em->getRepository(OrganizerInvitation::class)->find($id);
if (!$invitation) {
throw $this->createNotFoundException();
}
$viewUrl = $this->generateUrl('app_invitation_view', [
'token' => $invitation->getToken(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $this->renderView('email/organizer_invitation.html.twig', [
'invitation' => $invitation,
'viewUrl' => $viewUrl,
]);
$mailerService->sendEmail(
$invitation->getEmail(),
'Invitation organisateur - E-Ticket',
$html,
);
$invitation->setStatus(OrganizerInvitation::STATUS_SENT);
$invitation->setRespondedAt(null);
$em->flush();
$this->addFlash('success', 'Invitation renvoyee a '.$invitation->getEmail().'.');
return $this->redirectToRoute('app_admin_invite_organizer');
}
}