Add audit trail: AuditLog entity, AuditService, admin logs page

- AuditLog entity: action, entityType, entityId, data (JSON), performedBy, ipAddress
- AuditService: logs actions with current user and IP
- Audit on: order_created, order_paid, order_cancelled, order_refunded
- Admin /admin/logs: paginated table with action badges, details, user, IP
- Navigation link 'Logs' in admin header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-22 20:48:10 +01:00
parent 6ae9b1c7ff
commit 66ac2379ec
12 changed files with 350 additions and 6 deletions

View File

@@ -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

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260322120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create audit_log table';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -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

View File

@@ -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']);

View File

@@ -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
{

View File

@@ -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);
}

105
src/Entity/AuditLog.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
namespace App\Entity;
use App\Repository\AuditLogRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
class AuditLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50)]
private string $action;
#[ORM\Column(length: 255)]
private string $entityType;
#[ORM\Column(nullable: true)]
private ?int $entityId = null;
#[ORM\Column(type: 'json')]
private array $data = [];
#[ORM\Column(length: 255, nullable: true)]
private ?string $performedBy = null;
#[ORM\Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(string $action, string $entityType, ?int $entityId = null)
{
$this->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;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\AuditLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
class AuditLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Service;
use App\Entity\AuditLog;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
class AuditService
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
private RequestStack $requestStack,
) {
}
public function log(string $action, string $entityType, ?int $entityId = null, array $data = []): void
{
$log = new AuditLog($action, $entityType, $entityId);
$log->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();
}
}

View File

@@ -22,6 +22,7 @@
<a href="{{ path('app_admin_buyers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_buyer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Acheteurs</a>
<a href="{{ path('app_admin_organizers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_organizer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Organisateurs</a>
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
</nav>
</div>
<div class="flex items-center gap-3">

View File

@@ -0,0 +1,69 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Logs{% endblock %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Audit trail</h1>
<p class="font-bold text-gray-500 italic">{{ logs.getTotalItemCount }} action{{ logs.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<div class="admin-card !p-0">
<table class="admin-table">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Date</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Action</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Entite</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Details</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Par</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">IP</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-all">
<td class="text-xs text-gray-500 whitespace-nowrap">{{ log.createdAt|date('d/m/Y H:i:s') }}</td>
<td>
{% if log.action == 'order_created' %}
<span class="admin-badge-yellow text-xs font-black uppercase">Commande</span>
{% elseif log.action == 'order_paid' %}
<span class="admin-badge-green text-xs font-black uppercase">Payee</span>
{% elseif log.action == 'order_cancelled' %}
<span class="admin-badge-red text-xs font-black uppercase">Annulee</span>
{% elseif log.action == 'order_refunded' %}
<span class="admin-badge-yellow text-xs font-black uppercase">Remboursee</span>
{% else %}
<span class="admin-badge-indigo text-xs font-black uppercase">{{ log.action }}</span>
{% endif %}
</td>
<td class="text-xs text-gray-600">{{ log.entityType }} #{{ log.entityId }}</td>
<td class="text-xs text-gray-500">
{% for key, value in log.data %}
<span class="font-bold">{{ key }}</span>: {{ value }}{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
<td class="text-xs text-gray-600">{{ log.performedBy ?? '—' }}</td>
<td class="text-xs text-gray-400 font-mono">{{ log.ipAddress ?? '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="!text-center !py-12 text-gray-400 font-bold text-sm">Aucun log.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.getTotalItemCount > 30 %}
<div class="flex justify-center gap-2 mt-6">
{% for page in 1..logs.getPageCount %}
{% if page == logs.getCurrentPageNumber %}
<span class="admin-page-active text-xs font-black">{{ page }}</span>
{% else %}
<a href="{{ path('app_admin_logs', {page: page}) }}" class="admin-page-link text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Tests\Entity;
use App\Entity\AuditLog;
use PHPUnit\Framework\TestCase;
class AuditLogTest extends TestCase
{
public function testConstructor(): void
{
$log = new AuditLog('order_created', 'BilletBuyer', 42);
self::assertNull($log->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);
}
}