feat(audit): add history tracking and bump version to 1.1.2

This commit is contained in:
Matthieu
2026-01-25 21:19:42 +01:00
parent 4acc8d1c01
commit 034c193e4b
12 changed files with 2157 additions and 122 deletions

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ComposantHistoryController
{
public function __construct(
private readonly ComposantRepository $components,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$component = $this->components->find($id);
if (!$component) {
return new JsonResponse(
['message' => 'Composant introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('composant', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PieceHistoryController
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$piece = $this->pieces->find($id);
if (!$piece) {
return new JsonResponse(
['message' => 'Pièce introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('piece', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ProductHistoryController
{
public function __construct(
private readonly ProductRepository $products,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$product = $this->products->find($id);
if (!$product) {
return new JsonResponse(
['message' => 'Produit introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('product', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ($actorIds !== []) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

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

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
#[ORM\Table(name: 'audit_logs')]
#[ORM\Index(name: 'idx_audit_entity', columns: ['entityType', 'entityId'])]
#[ORM\Index(name: 'idx_audit_created_at', columns: ['createdAt'])]
#[ORM\HasLifecycleCallbacks]
class AuditLog
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 50)]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36)]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $action;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $diff = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $snapshot = null;
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
public function __construct(
string $entityType,
string $entityId,
string $action,
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
}
#[ORM\PrePersist]
public function initializeAuditLog(): void
{
if (!isset($this->createdAt)) {
$this->createdAt = new DateTimeImmutable();
}
if ($this->id === null) {
$this->id = $this->generateCuid();
}
}
public function getId(): ?string
{
return $this->id;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function getAction(): string
{
return $this->action;
}
public function getDiff(): ?array
{
return $this->diff;
}
public function getSnapshot(): ?array
{
return $this->snapshot;
}
public function getActorProfileId(): ?string
{
return $this->actorProfileId;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
private function generateCuid(): string
{
// Keep the same lightweight CUID-like strategy used across the project.
return 'cl'.substr(strtolower(base_convert(bin2hex(random_bytes(12)), 16, 36)), 0, 24);
}
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\ModelType;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
#[AsDoctrineListener(event: Events::onFlush)]
final class ComposantAuditSubscriber implements EventSubscriber
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingComponents = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotComposant($entity);
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$componentId = (string) $entity->getId();
if ($componentId === '') {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
$pendingComponents[$componentId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Composant) {
continue;
}
$snapshot = $this->snapshotComposant($entity);
$this->persistAuditLog($em, new AuditLog('composant', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
foreach ($pendingUpdates as $componentId => $diff) {
if ($diff === []) {
continue;
}
$component = $pendingComponents[$componentId] ?? null;
if (!$component instanceof Composant) {
continue;
}
$snapshot = $pendingSnapshots[$componentId] ?? $this->snapshotComposant($component);
$this->persistAuditLog($em, new AuditLog('composant', $componentId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Composant> $pendingComponents
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Composant) {
return;
}
$componentId = (string) $owner->getId();
if ($componentId === '') {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($owner);
$pendingComponents[$componentId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotComposant(Composant $component): array
{
return [
'id' => $component->getId(),
'name' => $component->getName(),
'reference' => $component->getReference(),
'prix' => $component->getPrix(),
'structure' => $component->getStructure(),
'typeComposant' => $this->normalizeValue($component->getTypeComposant()),
'product' => $this->normalizeValue($component->getProduct()),
'constructeurIds' => $this->normalizeCollection($component->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
* @return list<string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
return (string) $profileId;
}
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
#[AsDoctrineListener(event: Events::onFlush)]
final class PieceAuditSubscriber implements EventSubscriber
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingPieces = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotPiece($entity);
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$pieceId = (string) $entity->getId();
if ($pieceId === '') {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
$pendingPieces[$pieceId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Piece) {
continue;
}
$snapshot = $this->snapshotPiece($entity);
$this->persistAuditLog($em, new AuditLog('piece', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
foreach ($pendingUpdates as $pieceId => $diff) {
if ($diff === []) {
continue;
}
$piece = $pendingPieces[$pieceId] ?? null;
if (!$piece instanceof Piece) {
continue;
}
$snapshot = $pendingSnapshots[$pieceId] ?? $this->snapshotPiece($piece);
$this->persistAuditLog($em, new AuditLog('piece', $pieceId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Piece> $pendingPieces
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Piece) {
return;
}
$pieceId = (string) $owner->getId();
if ($pieceId === '') {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($owner);
$pendingPieces[$pieceId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotPiece(Piece $piece): array
{
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePiece' => $this->normalizeValue($piece->getTypePiece()),
'product' => $this->normalizeValue($piece->getProduct()),
'productIds' => $piece->getProductIds(),
'constructeurIds' => $this->normalizeCollection($piece->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
* @return list<string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
return (string) $profileId;
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
/**
* Record a lightweight, per-product audit trail.
*
* This MVP focuses on Product updates and captures:
* - scalar field changes (from Doctrine change sets)
* - constructeur collection changes (from collection updates)
*/
#[AsDoctrineListener(event: Events::onFlush)]
final class ProductAuditSubscriber implements EventSubscriber
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingProducts = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$productId = (string) $entity->getId();
if ($productId === '') {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Product) {
continue;
}
$snapshot = $this->snapshotProduct($entity);
$this->persistAuditLog($em, new AuditLog('product', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
// Capture constructeur collection updates, which are not included in the change set.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
foreach ($pendingUpdates as $productId => $diff) {
if ($diff === []) {
continue;
}
$product = $pendingProducts[$productId] ?? null;
if (!$product instanceof Product) {
continue;
}
$snapshot = $pendingSnapshots[$productId] ?? $this->snapshotProduct($product);
$this->persistAuditLog($em, new AuditLog('product', $productId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Product> $pendingProducts
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Product) {
return;
}
$productId = (string) $owner->getId();
if ($productId === '') {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($owner);
$pendingProducts[$productId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
// Ensure identifiers and timestamps are set even when persisting during onFlush.
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically.
if ($field === 'updatedAt' || $field === 'createdAt') {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotProduct(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProduct' => $this->normalizeValue($product->getTypeProduct()),
'constructeurIds' => $this->normalizeCollection($product->getConstructeurs()),
];
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
/**
* @param iterable<mixed> $items
* @return list<string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
return $value;
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
return (string) $profileId;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AuditLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
final class AuditLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
/**
* @return list<AuditLog>
*/
public function findEntityHistory(string $entityType, string $entityId, int $limit = 100): array
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}