diff --git a/TASK_CHECKUP.md b/TASK_CHECKUP.md index f0ad16e..87d799d 100644 --- a/TASK_CHECKUP.md +++ b/TASK_CHECKUP.md @@ -28,7 +28,7 @@ - [ ] Admin : liste de toutes les commandes avec filtres - [ ] Admin : pouvoir suspendre un organisateur - [ ] Admin : pouvoir modifier l'offre/commission d'un orga existant (déjà fait partiellement) - - [ ] Admin : logs des actions importantes (audit trail) +- [x] Admin : logs des actions importantes (audit trail: commande, paiement, annulation, remboursement) ### UX & Pages - [x] Page /tarifs : détailler les 3 offres (free/basic/custom) avec commissions et exemples diff --git a/migrations/Version20260322120000.php b/migrations/Version20260322120000.php new file mode 100644 index 0000000..8285799 --- /dev/null +++ b/migrations/Version20260322120000.php @@ -0,0 +1,28 @@ +addSql('CREATE TABLE audit_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(50) NOT NULL, entity_type VARCHAR(255) NOT NULL, entity_id INT DEFAULT NULL, data JSON NOT NULL, performed_by VARCHAR(255) DEFAULT NULL, ip_address VARCHAR(45) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_AUDIT_ACTION ON audit_log (action)'); + $this->addSql('CREATE INDEX IDX_AUDIT_CREATED ON audit_log (created_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE audit_log'); + } +} diff --git a/phpstan.neon b/phpstan.neon index 9dcbdc6..417c2c3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,7 +6,7 @@ parameters: - src/Kernel.php ignoreErrors: - - message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder|OrganizerInvitation)::\$id .* never assigned#' + message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder|OrganizerInvitation|AuditLog)::\$id .* never assigned#' reportUnmatched: false paths: - src/Entity/EmailTracking.php @@ -21,6 +21,7 @@ parameters: - src/Entity/BilletBuyerItem.php - src/Entity/BilletOrder.php - src/Entity/OrganizerInvitation.php + - src/Entity/AuditLog.php - message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#' path: src/Controller/OrderController.php diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index e631c3d..dad8a8f 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -11,6 +11,7 @@ use App\Entity\Category; use App\Entity\Event; use App\Entity\Payout; use App\Entity\User; +use App\Service\AuditService; use App\Service\BilletOrderService; use App\Service\EventIndexService; use App\Service\MailerService; @@ -817,7 +818,7 @@ class AccountController extends AbstractController } #[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])] - public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em): Response + public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); @@ -841,6 +842,11 @@ class AccountController extends AbstractController $em->flush(); + $audit->log('order_cancelled', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'event' => $event->getTitle(), + ]); + $this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); @@ -850,7 +856,7 @@ class AccountController extends AbstractController * @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])] - public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService): Response + public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); @@ -884,6 +890,12 @@ class AccountController extends AbstractController $em->flush(); + $audit->log('order_refunded', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'event' => $event->getTitle(), + 'totalHT' => $order->getTotalHTDecimal(), + ]); + $this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 18e3de8..3e47d9c 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Entity\AuditLog; use App\Entity\OrganizerInvitation; use App\Entity\User; use App\Service\MailerService; @@ -443,6 +444,17 @@ class AdminController extends AbstractController ]); } + #[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('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])] public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response { diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index 0b53f65..b14460e 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -8,6 +8,7 @@ use App\Entity\BilletBuyerItem; use App\Entity\BilletOrder; use App\Entity\Event; use App\Entity\User; +use App\Service\AuditService; use App\Service\BilletOrderService; use App\Service\InvoiceService; use App\Service\StripeService; @@ -22,7 +23,7 @@ class OrderController extends AbstractController private const REF_PATTERN = 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'; #[Route('/evenement/{id}/commander', name: 'app_order_create', requirements: ['id' => '\d+'], methods: ['POST'])] - public function create(int $id, Request $request, EntityManagerInterface $em): Response + public function create(int $id, Request $request, EntityManagerInterface $em, AuditService $audit): Response { $event = $em->getRepository(Event::class)->find($id); if (!$event || !$event->isOnline()) { @@ -67,6 +68,12 @@ class OrderController extends AbstractController $em->persist($order); $em->flush(); + $audit->log('order_created', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'event' => $event->getTitle(), + 'totalHT' => $order->getTotalHTDecimal(), + ]); + $redirect = $user ? $this->generateUrl('app_order_payment', ['id' => $order->getId()]) : $this->generateUrl('app_order_guest', ['id' => $order->getId()]); @@ -185,7 +192,7 @@ class OrderController extends AbstractController } #[Route('/commande/{id}/confirmation', name: 'app_order_success', requirements: ['id' => '\d+'], methods: ['GET'])] - public function success(int $id, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService, StripeService $stripeService): Response + public function success(int $id, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService, StripeService $stripeService, AuditService $audit): Response { $order = $em->getRepository(BilletBuyer::class)->find($id); if (!$order) { @@ -198,6 +205,10 @@ class OrderController extends AbstractController if ('succeeded' === $redirectStatus && BilletBuyer::STATUS_PENDING === $order->getStatus()) { $this->savePaymentDetails($order, $paymentIntentId, $stripeService, $em); $billetOrderService->generateOrderTickets($order); + $audit->log('order_paid', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'totalHT' => $order->getTotalHTDecimal(), + ]); $billetOrderService->generateAndSendTickets($order); } diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php new file mode 100644 index 0000000..40784b3 --- /dev/null +++ b/src/Entity/AuditLog.php @@ -0,0 +1,105 @@ +action = $action; + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAction(): string + { + return $this->action; + } + + public function getEntityType(): string + { + return $this->entityType; + } + + public function getEntityId(): ?int + { + return $this->entityId; + } + + public function getData(): array + { + return $this->data; + } + + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function getPerformedBy(): ?string + { + return $this->performedBy; + } + + public function setPerformedBy(?string $performedBy): static + { + $this->performedBy = $performedBy; + + return $this; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): static + { + $this->ipAddress = $ipAddress; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php new file mode 100644 index 0000000..8f5a971 --- /dev/null +++ b/src/Repository/AuditLogRepository.php @@ -0,0 +1,18 @@ + + */ +class AuditLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AuditLog::class); + } +} diff --git a/src/Service/AuditService.php b/src/Service/AuditService.php new file mode 100644 index 0000000..093c43c --- /dev/null +++ b/src/Service/AuditService.php @@ -0,0 +1,37 @@ +setData($data); + + $user = $this->security->getUser(); + if ($user) { + $log->setPerformedBy($user->getUserIdentifier()); + } + + $request = $this->requestStack->getCurrentRequest(); + if ($request) { + $log->setIpAddress($request->getClientIp()); + } + + $this->em->persist($log); + $this->em->flush(); + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 9778124..2bf5c64 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -22,6 +22,7 @@ Acheteurs Organisateurs Evenements + Logs
diff --git a/templates/admin/logs.html.twig b/templates/admin/logs.html.twig new file mode 100644 index 0000000..b591e7a --- /dev/null +++ b/templates/admin/logs.html.twig @@ -0,0 +1,69 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Logs{% endblock %} + +{% block body %} +
+

Audit trail

+

{{ logs.getTotalItemCount }} action{{ logs.getTotalItemCount > 1 ? 's' : '' }}.

+
+ +
+ + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
DateActionEntiteDetailsParIP
{{ log.createdAt|date('d/m/Y H:i:s') }} + {% if log.action == 'order_created' %} + Commande + {% elseif log.action == 'order_paid' %} + Payee + {% elseif log.action == 'order_cancelled' %} + Annulee + {% elseif log.action == 'order_refunded' %} + Remboursee + {% else %} + {{ log.action }} + {% endif %} + {{ log.entityType }} #{{ log.entityId }} + {% for key, value in log.data %} + {{ key }}: {{ value }}{% if not loop.last %}, {% endif %} + {% endfor %} + {{ log.performedBy ?? '—' }}{{ log.ipAddress ?? '—' }}
Aucun log.
+
+ +{% if logs.getTotalItemCount > 30 %} +
+ {% for page in 1..logs.getPageCount %} + {% if page == logs.getCurrentPageNumber %} + {{ page }} + {% else %} + {{ page }} + {% endif %} + {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/tests/Entity/AuditLogTest.php b/tests/Entity/AuditLogTest.php new file mode 100644 index 0000000..b562bc4 --- /dev/null +++ b/tests/Entity/AuditLogTest.php @@ -0,0 +1,50 @@ +getId()); + self::assertSame('order_created', $log->getAction()); + self::assertSame('BilletBuyer', $log->getEntityType()); + self::assertSame(42, $log->getEntityId()); + self::assertSame([], $log->getData()); + self::assertNull($log->getPerformedBy()); + self::assertNull($log->getIpAddress()); + self::assertInstanceOf(\DateTimeImmutable::class, $log->getCreatedAt()); + } + + public function testSetData(): void + { + $log = new AuditLog('test', 'Entity'); + $result = $log->setData(['key' => 'value']); + + self::assertSame(['key' => 'value'], $log->getData()); + self::assertSame($log, $result); + } + + public function testSetPerformedBy(): void + { + $log = new AuditLog('test', 'Entity'); + $result = $log->setPerformedBy('admin@test.fr'); + + self::assertSame('admin@test.fr', $log->getPerformedBy()); + self::assertSame($log, $result); + } + + public function testSetIpAddress(): void + { + $log = new AuditLog('test', 'Entity'); + $result = $log->setIpAddress('127.0.0.1'); + + self::assertSame('127.0.0.1', $log->getIpAddress()); + self::assertSame($log, $result); + } +}