The constant was incorrectly defined as self::NO_SNAPSHOT_MSG = self::NO_SNAPSHOT_MSG causing a PHP fatal error. Replace with the actual string value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
994 lines
40 KiB
PHP
994 lines
40 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';
|
|
private const DQL_EXCLUDE_INVITATIONS = 'o.isInvitation = false OR o.isInvitation IS NULL';
|
|
private const NO_SNAPSHOT_MSG = 'Aucun snapshot disponible.';
|
|
|
|
#[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(self::DQL_EXCLUDE_INVITATIONS)
|
|
->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(self::DQL_EXCLUDE_INVITATIONS)
|
|
->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(self::DQL_EXCLUDE_INVITATIONS)
|
|
->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)) {
|
|
$emptyServer = [
|
|
'hostname' => '?', 'os' => '?', 'uptime' => '?',
|
|
'cpu_model' => '?', 'cpu_cores' => '?', 'load_1m' => '?', 'load_5m' => '?', 'load_15m' => '?', 'load_percent' => '?',
|
|
'ram_total' => '?', 'ram_used' => '?', 'ram_free' => '?', 'ram_percent' => '?',
|
|
'disk_total' => '?', 'disk_used' => '?', 'disk_free' => '?', 'disk_percent' => '?',
|
|
'caddy' => ['status' => 'unknown', 'info' => '?'], 'docker' => ['status' => 'unknown', 'info' => '?'],
|
|
'ssl' => ['domain' => '?', 'issuer' => '?', 'valid_until' => '?', 'days_left' => '?', 'status' => 'unknown'],
|
|
];
|
|
|
|
return $this->render('admin/infra.html.twig', [
|
|
'snapshot_missing' => true,
|
|
'server' => $emptyServer, 'containers' => [], 'redis_global' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
|
'redis_dbs' => [], 'postgres' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG], 'pgbouncer' => ['connected' => false, 'error' => self::NO_SNAPSHOT_MSG],
|
|
'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');
|
|
}
|
|
}
|