Compare commits

...

10 Commits

Author SHA1 Message Date
Matthieu
f970c1928d fix(api) : cap pagination to 200 items/page to prevent OOM in production
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:11:09 +01:00
Matthieu
2a1d966b87 chore(frontend) : update submodule — smart cache on composables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:50 +01:00
Matthieu
a393b62e9f chore(frontend) : update submodule — Malio brand colors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:30 +01:00
Matthieu
1247f72af6 chore(frontend) : update submodule — activity log + clickable catalog types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:26 +01:00
Matthieu
6735bf252c feat(activity-log) : add paginated activity log endpoint and store constructeur names in audit
- New GET /api/activity-logs endpoint with pagination and filters
  (entityType, action) for the global activity log page
- Add findAllPaginated() to AuditLogRepository
- normalizeCollection() now stores {id, name} objects instead of
  bare IDs so constructeur changes display readable names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:19 +01:00
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
Matthieu
16a7eac0c6 chore(release) : v1.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:59:55 +01:00
Matthieu
37ac08b182 chore(frontend) : update submodule — edit pages optimization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:50 +01:00
Matthieu
5ef80b362e perf(api) : add serialization groups to CustomFieldValue and CustomField
Expose customField definitions (id, name, type, required, options, orderIndex)
inline in entity responses, eliminating separate API calls for custom field data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:43 +01:00
17 changed files with 631 additions and 196 deletions

View File

@@ -1 +1 @@
1.3.0
1.4.0

View File

@@ -1,7 +1,9 @@
api_platform:
title: Hello API Platform
version: 1.3.0
version: 1.4.0
defaults:
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination_items_per_page: 30
pagination_maximum_items_per_page: 200

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ActivityLogController
{
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/activity-logs', name: 'api_activity_logs', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$page = max(1, $request->query->getInt('page', 1));
$itemsPerPage = min(100, max(1, $request->query->getInt('itemsPerPage', 30)));
$filters = [];
if ($entityType = $request->query->get('entityType')) {
$filters['entityType'] = $entityType;
}
if ($action = $request->query->get('action')) {
$filters['action'] = $action;
}
$result = $this->auditLogs->findAllPaginated($page, $itemsPerPage, $filters);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$result['items'],
))));
$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();
$snapshot = $log->getSnapshot();
return [
'id' => $log->getId(),
'entityType' => $log->getEntityType(),
'entityId' => $log->getEntityId(),
'entityName' => $snapshot['name'] ?? null,
'entityRef' => $snapshot['reference'] ?? null,
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $snapshot,
];
},
$result['items'],
);
return new JsonResponse([
'items' => array_values($items),
'total' => $result['total'],
'page' => $page,
'itemsPerPage' => $itemsPerPage,
]);
}
}

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Composant
{

View File

@@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Constructeur
{

View File

@@ -6,10 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
#[ORM\Table(name: 'custom_fields')]
@@ -19,24 +21,30 @@ class CustomField
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $type;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $required = false;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
private ?string $defaultValue = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?array $options = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private int $orderIndex = 0;
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'customFields')]
@@ -62,10 +70,10 @@ class CustomField
private Collection $customFieldValues;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -75,11 +83,11 @@ class CustomField
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -87,12 +95,7 @@ class CustomField
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -191,13 +194,18 @@ class CustomField
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -6,8 +6,10 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
#[ORM\Table(name: 'custom_field_values')]
@@ -17,13 +19,16 @@ class CustomFieldValue
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $value;
#[ORM\ManyToOne(targetEntity: CustomField::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'customFieldId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private CustomField $customField;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFieldValues')]
@@ -43,19 +48,21 @@ class CustomFieldValue
private ?Product $product = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -63,12 +70,7 @@ class CustomFieldValue
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -155,13 +157,18 @@ class CustomFieldValue
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -23,7 +23,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
#[ApiResource(
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class ModelType
{

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['piece:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Piece
{

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['product:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Product
{

View File

@@ -30,7 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class Site
{

View File

@@ -34,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500
paginationMaximumItemsPerPage: 200
)]
class TypeMachine
{

View File

@@ -6,8 +6,11 @@ namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
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)]
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
{
@@ -61,12 +73,12 @@ final class ComposantAuditSubscriber implements EventSubscriber
}
$componentId = (string) $entity->getId();
if ($componentId === '') {
if ('' === $componentId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
if ([] !== $diff) {
$pendingUpdates[$componentId] = $this->mergeDiffs($pendingUpdates[$componentId] ?? [], $diff);
$pendingSnapshots[$componentId] = $this->snapshotComposant($entity);
$pendingComponents[$componentId] = $entity;
@@ -89,8 +101,10 @@ final class ComposantAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingComponents);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingComponents);
foreach ($pendingUpdates as $componentId => $diff) {
if ($diff === []) {
if ([] === $diff) {
continue;
}
@@ -125,13 +139,13 @@ final class ComposantAuditSubscriber implements EventSubscriber
}
$componentId = (string) $owner->getId();
if ($componentId === '') {
if ('' === $componentId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
if ('constructeurs' !== $fieldName) {
return;
}
@@ -154,6 +168,75 @@ final class ComposantAuditSubscriber implements EventSubscriber
$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
{
$uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class ComposantAuditSubscriber implements EventSubscriber
/**
* @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') {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
@@ -208,33 +292,39 @@ final class ComposantAuditSubscriber implements EventSubscriber
/**
* @param iterable<mixed> $items
* @return list<string>
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
$entries = [];
$seen = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
@@ -257,11 +347,11 @@ final class ComposantAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
if (is_array($value)) {
return $value;
}
@@ -271,6 +361,7 @@ final class ComposantAuditSubscriber implements EventSubscriber
/**
* @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
@@ -284,17 +375,23 @@ final class ComposantAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
if ($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;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
@@ -15,15 +18,24 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
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)]
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
{
@@ -61,12 +73,12 @@ final class PieceAuditSubscriber implements EventSubscriber
}
$pieceId = (string) $entity->getId();
if ($pieceId === '') {
if ('' === $pieceId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
if ([] !== $diff) {
$pendingUpdates[$pieceId] = $this->mergeDiffs($pendingUpdates[$pieceId] ?? [], $diff);
$pendingSnapshots[$pieceId] = $this->snapshotPiece($entity);
$pendingPieces[$pieceId] = $entity;
@@ -89,8 +101,10 @@ final class PieceAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingPieces);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingPieces);
foreach ($pendingUpdates as $pieceId => $diff) {
if ($diff === []) {
if ([] === $diff) {
continue;
}
@@ -125,13 +139,13 @@ final class PieceAuditSubscriber implements EventSubscriber
}
$pieceId = (string) $owner->getId();
if ($pieceId === '') {
if ('' === $pieceId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
if ('constructeurs' !== $fieldName) {
return;
}
@@ -154,6 +168,75 @@ final class PieceAuditSubscriber implements EventSubscriber
$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
{
$uow = $em->getUnitOfWork();
@@ -166,13 +249,14 @@ final class PieceAuditSubscriber implements EventSubscriber
/**
* @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') {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
@@ -208,33 +292,39 @@ final class PieceAuditSubscriber implements EventSubscriber
/**
* @param iterable<mixed> $items
* @return list<string>
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
$entries = [];
$seen = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
@@ -257,11 +347,11 @@ final class PieceAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
if (is_array($value)) {
return $value;
}
@@ -271,6 +361,7 @@ final class PieceAuditSubscriber implements EventSubscriber
/**
* @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
@@ -284,17 +375,23 @@ final class PieceAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
if ($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;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
@@ -14,8 +17,16 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
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.
@@ -27,9 +38,10 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
#[AsDoctrineListener(event: Events::onFlush)]
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
{
@@ -67,12 +79,12 @@ final class ProductAuditSubscriber implements EventSubscriber
}
$productId = (string) $entity->getId();
if ($productId === '') {
if ('' === $productId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ($diff !== []) {
if ([] !== $diff) {
$pendingUpdates[$productId] = $this->mergeDiffs($pendingUpdates[$productId] ?? [], $diff);
$pendingSnapshots[$productId] = $this->snapshotProduct($entity);
$pendingProducts[$productId] = $entity;
@@ -96,8 +108,10 @@ final class ProductAuditSubscriber implements EventSubscriber
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingProducts);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingProducts);
foreach ($pendingUpdates as $productId => $diff) {
if ($diff === []) {
if ([] === $diff) {
continue;
}
@@ -132,13 +146,13 @@ final class ProductAuditSubscriber implements EventSubscriber
}
$productId = (string) $owner->getId();
if ($productId === '') {
if ('' === $productId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ($fieldName !== 'constructeurs') {
if ('constructeurs' !== $fieldName) {
return;
}
@@ -161,6 +175,75 @@ final class ProductAuditSubscriber implements EventSubscriber
$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
{
$uow = $em->getUnitOfWork();
@@ -174,6 +257,7 @@ final class ProductAuditSubscriber implements EventSubscriber
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
@@ -181,7 +265,7 @@ final class ProductAuditSubscriber implements EventSubscriber
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
// Skip noisy timestamps managed automatically.
if ($field === 'updatedAt' || $field === 'createdAt') {
if ('updatedAt' === $field || 'createdAt' === $field) {
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}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
@@ -229,33 +314,39 @@ final class ProductAuditSubscriber implements EventSubscriber
/**
* @param iterable<mixed> $items
* @return list<string>
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$ids = [];
$entries = [];
$seen = [];
foreach ($items as $item) {
if (\is_object($item) && \method_exists($item, 'getId')) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if ($id !== null && $id !== '') {
$ids[] = (string) $id;
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
sort($ids);
return array_values(array_unique($ids));
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if ($value === null || \is_scalar($value)) {
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelType) {
@@ -270,11 +361,11 @@ final class ProductAuditSubscriber implements EventSubscriber
return $this->normalizeCollection($value);
}
if (\is_object($value) && \method_exists($value, 'getId')) {
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (\is_array($value)) {
if (is_array($value)) {
return $value;
}
@@ -283,16 +374,23 @@ final class ProductAuditSubscriber implements EventSubscriber
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if (!$session instanceof SessionInterface) {
return null;
}
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if (!$profileId) {
return null;
}
if ($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

@@ -31,7 +31,46 @@ final class AuditLogRepository extends ServiceEntityRepository
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
->getResult()
;
}
/**
* @param array{entityType?: string, action?: string} $filters
*
* @return array{items: list<AuditLog>, total: int}
*/
public function findAllPaginated(int $page = 1, int $itemsPerPage = 30, array $filters = []): array
{
$qb = $this->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
;
if (!empty($filters['entityType'])) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $filters['entityType'])
;
}
if (!empty($filters['action'])) {
$qb->andWhere('a.action = :action')
->setParameter('action', $filters['action'])
;
}
$countQb = clone $qb;
$countQb->select('COUNT(a.id)')
->resetDQLPart('orderBy')
;
$total = (int) $countQb->getQuery()->getSingleScalarResult();
$qb->setFirstResult(($page - 1) * $itemsPerPage)
->setMaxResults($itemsPerPage)
;
return [
'items' => $qb->getQuery()->getResult(),
'total' => $total,
];
}
}