Compare commits

...

2 Commits

Author SHA1 Message Date
Matthieu
508066d39f fix(frontend) : update submodule with custom field display fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:12 +01:00
Matthieu
70956c204e fix(audit) : inject Security for actor resolution + track custom field changes
- Inject Security service into all 3 audit subscribers to resolve
  actor profile from authenticated user (fixes "Par Inconnu" issue)
- Add CustomFieldValue tracking: insertions, updates, and deletions
  on custom field values now produce audit log entries on the parent
  entity (composant, piece, product) with field name prefix
  "customField:{name}"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:07 +01:00
4 changed files with 420 additions and 143 deletions

View File

@@ -6,8 +6,11 @@ namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
final class ComposantAuditSubscriber implements EventSubscriber final class ComposantAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -61,12 +73,12 @@ final class ComposantAuditSubscriber implements EventSubscriber
} }
$componentId = (string) $entity->getId(); $componentId = (string) $entity->getId();
if ($componentId === '') { if ('' === $componentId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff); $pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity); $pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
$pendingComponents[$componentId] = $entity; $pendingComponents[$componentId] = $entity;
@@ -89,8 +101,10 @@ final class ComposantAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
foreach ($pendingUpdates as $componentId => $diff) { foreach ($pendingUpdates as $componentId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -125,13 +139,13 @@ final class ComposantAuditSubscriber implements EventSubscriber
} }
$componentId = (string) $owner->getId(); $componentId = (string) $owner->getId();
if ($componentId === '') { if ('' === $componentId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -154,6 +168,75 @@ final class ComposantAuditSubscriber implements EventSubscriber
$pendingComponents[$componentId] = $owner; $pendingComponents[$componentId] = $owner;
} }
/**
* @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 collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
}
}
/**
* @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 trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingComponents,
): void {
$owner = $cfv->getComposant();
if (!$owner instanceof Composant) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotComposant($owner);
$pendingComponents[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
{ {
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -208,15 +292,16 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
*
* @return list<string> * @return list<string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $ids = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null !== $id && '' !== $id) {
$ids[] = (string) $id; $ids[] = (string) $id;
} }
} }
@@ -229,12 +314,12 @@ final class ComposantAuditSubscriber implements EventSubscriber
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -257,11 +342,11 @@ final class ComposantAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -271,6 +356,7 @@ final class ComposantAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -284,17 +370,23 @@ final class ComposantAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $profileId; return (string) $profileId;
} }
} }
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace App\EventSubscriber; namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Piece; use App\Entity\Piece;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
final class PieceAuditSubscriber implements EventSubscriber final class PieceAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -61,12 +73,12 @@ final class PieceAuditSubscriber implements EventSubscriber
} }
$pieceId = (string) $entity->getId(); $pieceId = (string) $entity->getId();
if ($pieceId === '') { if ('' === $pieceId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff); $pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity); $pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
$pendingPieces[$pieceId] = $entity; $pendingPieces[$pieceId] = $entity;
@@ -89,8 +101,10 @@ final class PieceAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
foreach ($pendingUpdates as $pieceId => $diff) { foreach ($pendingUpdates as $pieceId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -125,13 +139,13 @@ final class PieceAuditSubscriber implements EventSubscriber
} }
$pieceId = (string) $owner->getId(); $pieceId = (string) $owner->getId();
if ($pieceId === '') { if ('' === $pieceId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -154,6 +168,75 @@ final class PieceAuditSubscriber implements EventSubscriber
$pendingPieces[$pieceId] = $owner; $pendingPieces[$pieceId] = $owner;
} }
/**
* @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 collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
}
}
/**
* @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 trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingPieces,
): void {
$owner = $cfv->getPiece();
if (!$owner instanceof Piece) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotPiece($owner);
$pendingPieces[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
{ {
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -208,15 +292,16 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
*
* @return list<string> * @return list<string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $ids = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null !== $id && '' !== $id) {
$ids[] = (string) $id; $ids[] = (string) $id;
} }
} }
@@ -229,12 +314,12 @@ final class PieceAuditSubscriber implements EventSubscriber
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -257,11 +342,11 @@ final class PieceAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -271,6 +356,7 @@ final class PieceAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -284,17 +370,23 @@ final class PieceAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $profileId; return (string) $profileId;
} }
} }
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\EventSubscriber; namespace App\EventSubscriber;
use App\Entity\AuditLog; use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
@@ -14,8 +17,16 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
/** /**
* Record a lightweight, per-product audit trail. * Record a lightweight, per-product audit trail.
@@ -27,9 +38,10 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
final class ProductAuditSubscriber implements EventSubscriber final class ProductAuditSubscriber implements EventSubscriber
{ {
public function __construct(private readonly RequestStack $requestStack) public function __construct(
{ private readonly RequestStack $requestStack,
} private readonly Security $security,
) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
{ {
@@ -67,12 +79,12 @@ final class ProductAuditSubscriber implements EventSubscriber
} }
$productId = (string) $entity->getId(); $productId = (string) $entity->getId();
if ($productId === '') { if ('' === $productId) {
continue; continue;
} }
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity)); $diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) { if ([] !== $diff) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff); $pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity); $pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity; $pendingProducts[$productId] = $entity;
@@ -96,8 +108,10 @@ final class ProductAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts); $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
} }
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
foreach ($pendingUpdates as $productId => $diff) { foreach ($pendingUpdates as $productId => $diff) {
if ($diff === []) { if ([] === $diff) {
continue; continue;
} }
@@ -132,13 +146,13 @@ final class ProductAuditSubscriber implements EventSubscriber
} }
$productId = (string) $owner->getId(); $productId = (string) $owner->getId();
if ($productId === '') { if ('' === $productId) {
return; return;
} }
$mapping = $collection->getMapping(); $mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null; $fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') { if ('constructeurs' !== $fieldName) {
return; return;
} }
@@ -161,6 +175,75 @@ final class ProductAuditSubscriber implements EventSubscriber
$pendingProducts[$productId] = $owner; $pendingProducts[$productId] = $owner;
} }
/**
* @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 collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
}
}
/**
* @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 trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingProducts,
): void {
$owner = $cfv->getProduct();
if (!$owner instanceof Product) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotProduct($owner);
$pendingProducts[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{ {
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
@@ -174,6 +257,7 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet * @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function buildDiffFromChangeSet(array $changeSet): array private function buildDiffFromChangeSet(array $changeSet): array
@@ -181,7 +265,7 @@ final class ProductAuditSubscriber implements EventSubscriber
$diff = []; $diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) { foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically. // Skip noisy timestamps managed automatically.
if ($field === 'updatedAt' || $field === 'createdAt') { if ('updatedAt' === $field || 'createdAt' === $field) {
continue; continue;
} }
@@ -216,6 +300,7 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param array<string, array{from:mixed, to:mixed}> $base * @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra * @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}> * @return array<string, array{from:mixed, to:mixed}>
*/ */
private function mergeDiffs(array $base, array $extra): array private function mergeDiffs(array $base, array $extra): array
@@ -229,15 +314,16 @@ final class ProductAuditSubscriber implements EventSubscriber
/** /**
* @param iterable<mixed> $items * @param iterable<mixed> $items
*
* @return list<string> * @return list<string>
*/ */
private function normalizeCollection(iterable $items): array private function normalizeCollection(iterable $items): array
{ {
$ids = []; $ids = [];
foreach ($items as $item) { foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) { if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId(); $id = $item->getId();
if ($id !== null && $id !== '') { if (null !== $id && '' !== $id) {
$ids[] = (string) $id; $ids[] = (string) $id;
} }
} }
@@ -250,12 +336,12 @@ final class ProductAuditSubscriber implements EventSubscriber
private function normalizeValue(mixed $value): mixed private function normalizeValue(mixed $value): mixed
{ {
if ($value === null || \is_scalar($value)) { if (null === $value || is_scalar($value)) {
return $value; return $value;
} }
if ($value instanceof \DateTimeInterface) { if ($value instanceof DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM); return $value->format(DateTimeInterface::ATOM);
} }
if ($value instanceof ModelType) { if ($value instanceof ModelType) {
@@ -270,11 +356,11 @@ final class ProductAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value); return $this->normalizeCollection($value);
} }
if (\is_object($value) && \method_exists($value, 'getId')) { if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId(); return (string) $value->getId();
} }
if (\is_array($value)) { if (is_array($value)) {
return $value; return $value;
} }
@@ -283,16 +369,23 @@ final class ProductAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string private function resolveActorProfileId(): ?string
{ {
try {
$session = $this->requestStack->getSession(); $session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) { if ($session instanceof SessionInterface) {
return null;
}
$profileId = $session->get('profileId'); $profileId = $session->get('profileId');
if (!$profileId) { if ($profileId) {
return null;
}
return (string) $profileId; return (string) $profileId;
} }
} }
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}