feat : add audit log (table, writer, listener, API, admin UI, timeline)

Implemente le journal d'audit append-only sur toutes les mutations Doctrine
des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md :

1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer)
   + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth
   sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par
   requete HTTP principale).
2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/
   + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM,
   pattern swap-and-clear, erreurs loguees jamais propagees). User annote.
3. API Platform read-only /api/audit-logs (permission core.audit_log.view)
   avec filtres entity_type / entity_id / action / performed_by / plage
   performed_at + DbalPaginator implementant PaginatorInterface (hydra:view
   genere automatiquement).
4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query
   params, row expandable (diff + timeline de l'entite), entree sidebar avec
   permission. Composable useAuditLog avec resetAuditLog() auto-enregistre
   sur onAuthSessionCleared.
5. Composant AuditTimeline reutilisable : garde permission, lazy loading,
   dates relatives FR, skeleton loader.

Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via <env> ce qui cablait
framework.test=false et rendait test.service_container indisponible ; le
JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps
pour debloquer la suite de tests.
This commit is contained in:
2026-04-20 20:51:10 +02:00
parent 140dca9061
commit de39fe6a3e
31 changed files with 2754 additions and 6 deletions

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
/**
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
*
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
* automatiquement la section `hydra:view` (first / next / previous / last)
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
*
* @template T of object
*
* @implements PaginatorInterface<T>
*/
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
{
/**
* @param list<T> $items Items deja decoupes sur la page courante
* @param int $currentPage Page courante (1-indexee)
* @param int $itemsPerPage Limite appliquee a la requete SQL
* @param int $totalItems Resultat du COUNT(*) sans limite
*/
public function __construct(
private array $items,
private int $currentPage,
private int $itemsPerPage,
private int $totalItems,
) {}
public function getCurrentPage(): float
{
return (float) $this->currentPage;
}
public function getLastPage(): float
{
if ($this->itemsPerPage <= 0) {
return 1.0;
}
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
public function count(): int
{
return count($this->items);
}
/**
* @return Traversable<int, T>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
/**
* Resource API Platform en lecture seule sur le journal d'audit.
*
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
* conformement au caractere append-only de la table `audit_log`.
*
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
* provider lit via DBAL et retourne directement des instances du DTO
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
* l'ORM : aucune entite Doctrine n'est necessaire ici.
*
* Filtres query-param supportes par le provider :
* ?entity_type=core.User
* ?entity_id=42
* ?action=update
* ?performed_by=admin
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination est assuree par le provider via DbalPaginator (implementant
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
* automatiquement hydra:view — aucune construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
paginationItemsPerPage: 30,
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
new Get(
uriTemplate: '/audit-logs/{id}',
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
],
output: AuditLogOutput::class,
)]
final class AuditLogResource {}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider API Platform pour la resource AuditLog.
*
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
* - une instance unique d'AuditLogOutput (operation Get) ;
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
*
* Le paginator implementant PaginatorInterface laisse API Platform generer
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
*
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
* reservee a l'ecriture hors transaction ORM).
*/
final readonly class AuditLogProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) $uriVariables['id']);
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?AuditLogOutput
{
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
FROM audit_log WHERE id = :id',
['id' => $id],
);
if (false === $row) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
$page = $this->pagination->getPage($context);
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
$dataQuery = $this->buildBaseQuery()
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
->orderBy('performed_at', 'DESC')
->setFirstResult($offset)
->setMaxResults($itemsPerPage)
;
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
$this->applyFilters($dataQuery, $filters);
$this->applyFilters($countQuery, $filters);
/** @var list<array<string, mixed>> $rows */
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
private function buildBaseQuery(): QueryBuilder
{
return $this->connection->createQueryBuilder()->from('audit_log');
}
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) {
$filters['performed_at_after'] = $range['after'];
}
if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) {
$filters['performed_at_before'] = $range['before'];
}
}
return $filters;
}
/**
* @param array<string, string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
}
if (isset($filters['action'])) {
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
$qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $filters['performed_by']);
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
}
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): AuditLogOutput
{
/** @var string $rawChanges */
$rawChanges = $row['changes'] ?? '{}';
/** @var array<string, mixed> $changes */
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
return new AuditLogOutput(
id: (string) $row['id'],
entityType: (string) $row['entity_type'],
entityId: (string) $row['entity_id'],
action: (string) $row['action'],
changes: $changes,
performedBy: (string) $row['performed_by'],
performedAt: new DateTimeImmutable((string) $row['performed_at']),
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Service bas-niveau responsable de l'ecriture dans la table `audit_log`.
*
* Utilise une connexion DBAL dediee `audit` (meme DSN que `default`, service
* separe) pour ecrire hors de la transaction ORM : indispensable pour que
* les lignes d'audit survivent meme si le flush applicatif est rollback,
* et pour eviter tout entanglement transactionnel en batch (fixtures).
*
* Les cles sensibles (password, plainPassword, token, secret) sont filtrees
* en defense-in-depth meme si les entites declarent deja ces proprietes
* #[AuditIgnore].
*
* Erreur silencieuse : en cas d'echec SQL, on lance pas l'exception plus
* haut — l'audit ne doit jamais faire crasher un flux metier. Le listener
* wrappe l'appel dans un try/catch + logger (cf. AuditListener).
*/
final class AuditLogWriter
{
/** @var list<string> cles systematiquement strippees du payload `changes` */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {}
/**
* Ecrit une ligne d'audit.
*
* @param string $entityType Format "module.Entity" (ex: "core.User")
* @param string $entityId ID de l'entite (int ou UUID serialise)
* @param string $action create|update|delete
* @param array<string, mixed> $changes Payload JSON (filtre des cles sensibles)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
// Types de conversion DBAL : JSON encode jsonb + datetimetz.
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Supprime recursivement les cles sensibles du payload.
*
* Utile pour les snapshots complets (create/delete) ou les changes
* d'update : le listener prefiltre deja mais on garde cette garde
* en defense-in-depth si un appelant direct oublie `#[AuditIgnore]`.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
unset($data[$sensitiveKey]);
}
return $data;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Fournit un identifiant de requete HTTP (UUID v4) partage par toutes les
* lignes d'audit produites au cours d'une meme requete principale.
*
* Utilite : retrouver d'un seul coup d'oeil toutes les ecritures liees a un
* meme appel utilisateur (ex: PATCH qui cascade des updates sur plusieurs
* entites). Null en CLI (fixtures, commandes batch).
*
* Service singleton (scope container par defaut) — un unique UUID est
* genere au kernel.request principal et reutilise pour toute la requete.
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
// Ignorer les sub-requests (ESI, forward interne) pour ne pas
// ecraser l'UUID de la requete principale.
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Limitations connues :
* - Les changements de collections ManyToMany ne sont pas tracees
* (`getEntityChangeSet()` ne les couvre pas). Extension future via
* `getScheduledCollectionUpdates()`.
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
/**
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
* Evite une ReflectionClass par entite a chaque flush.
*
* @var array<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
private array $ignoredPropertiesCache = [];
/**
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
*
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
* reference de l'entite et on resout l'ID au moment du postFlush.
*
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
*/
private array $pendingLogs = [];
public function __construct(
private readonly AuditLogWriter $writer,
private readonly LoggerInterface $logger,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
/** @var EntityManagerInterface $em */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'update');
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'delete');
}
}
public function postFlush(PostFlushEventArgs $args): void
{
// Swap-and-clear : protege d'un flush re-entrant (aucune double
// insertion meme si un callback utilisateur re-declenche un flush).
$logs = $this->pendingLogs;
$this->pendingLogs = [];
foreach ($logs as $log) {
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
if (null === $entityId) {
$this->logger->warning(
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
['entityType' => $log['entityType'], 'action' => $log['action']]
);
continue;
}
try {
$this->writer->log(
$log['entityType'],
$entityId,
$log['action'],
$log['changes'],
);
} catch (Throwable $e) {
// Erreur audit : logue mais ne crashe jamais le flux metier.
$this->logger->error(
'Echec d\'ecriture audit_log',
[
'exception' => $e,
'entityType' => $log['entityType'],
'entityId' => $entityId,
'action' => $log['action'],
]
);
}
}
}
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
$class = $entity::class;
if (!$this->isAuditable($class)) {
return;
}
$metadata = $em->getClassMetadata($class);
$changes = match ($action) {
'update' => $this->buildUpdateChanges($entity, $uow, $class),
'create', 'delete' => $this->buildSnapshot($entity, $metadata, $class),
default => [],
};
if ('update' === $action && [] === $changes) {
// Flush sans changement reel sur une entite auditable : on n'emet pas.
return;
}
// Pour delete/update, l'ID est deja set en onFlush — on la capture
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
// le flush — on differe a postFlush.
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
$this->pendingLogs[] = [
'entity' => $entity,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => $action,
'changes' => $changes,
'capturedId' => $capturedId,
];
}
/**
* Build du changeset "update" : {champ: {old, new}} a partir de
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
* null-safe via `?->getId()`.
*
* @return array<string, array{old: mixed, new: mixed}>
*/
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
{
$changeSet = $uow->getEntityChangeSet($entity);
$ignored = $this->getIgnoredProperties($class);
$filteredChanges = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if (in_array($field, $ignored, true)) {
continue;
}
$filteredChanges[$field] = [
'old' => $this->normalizeValue($oldValue),
'new' => $this->normalizeValue($newValue),
];
}
return $filteredChanges;
}
/**
* Build d'un snapshot complet (create / delete) : lit toutes les
* proprietes non-ignorees via Reflection.
*
* @return array<string, mixed>
*/
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
foreach ($metadata->getFieldNames() as $field) {
if (in_array($field, $ignored, true)) {
continue;
}
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
}
foreach ($metadata->getAssociationNames() as $assoc) {
if (in_array($assoc, $ignored, true)) {
continue;
}
$mapping = $metadata->getAssociationMapping($assoc);
// On ne snapshot que les references scalaires (to-one) ; les
// collections to-many sont volumineuses et souvent non utiles
// a figer dans un audit (cf. limitation ManyToMany).
if (!$metadata->isSingleValuedAssociation($assoc)) {
continue;
}
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
}
return $snapshot;
}
private function isAuditable(string $class): bool
{
if (array_key_exists($class, $this->auditableCache)) {
return $this->auditableCache[$class];
}
$reflection = new ReflectionClass($class);
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
$this->auditableCache[$class] = $isAuditable;
return $isAuditable;
}
/**
* @return list<string>
*/
private function getIgnoredProperties(string $class): array
{
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
return $this->ignoredPropertiesCache[$class];
}
$ignored = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
if ([] !== $property->getAttributes(AuditIgnore::class)) {
$ignored[] = $property->getName();
}
}
$this->ignoredPropertiesCache[$class] = $ignored;
return $ignored;
}
/**
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
*
* Format `module.Entity` pour eviter les collisions inter-modules.
*/
private function formatEntityType(string $class): string
{
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
return strtolower($matches['module']).'.'.$matches['entity'];
}
// Fallback : on retourne le FQCN complet si la regex ne matche pas
// (entite hors structure modulaire — ne devrait pas arriver).
return $class;
}
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
{
$identifier = $metadata->getIdentifierValues($entity);
if ([] === $identifier) {
return null;
}
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
}
/**
* Normalise une valeur pour encodage JSON stable.
*/
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if (is_object($value)) {
// Relation to-one non parsee par buildSnapshot (cas update sur
// un champ qui devient un objet) : on tente getId() si possible.
if (method_exists($value, 'getId')) {
return $value->getId();
}
return (string) $value;
}
return $value;
}
}