82 KiB
Entity Versioning Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add numbered versioning with restore capability to Machine, Composant, Piece, and Product entities, leveraging the existing AuditLog system.
Architecture: Extend AuditLog with a version column. Enrich snapshots to include slots/custom fields. Add EntityVersionController with list/preview/restore endpoints. New EntityVersionService centralizes restore logic with skeleton and integrity checks. Frontend gets a EntityVersionList component and VersionRestoreModal.
Tech Stack: Symfony 8 / PHP 8.4 / API Platform / PostgreSQL 16 / Nuxt 4 / Vue 3 / DaisyUI 5
File Structure
Backend — New Files
src/Service/EntityVersionService.php— Core restore logic (skeleton check, integrity check, apply restore)src/Controller/EntityVersionController.php— REST endpoints (versions list, preview, restore)migrations/VersionXXXX.php— Addversioncolumn toaudit_logsandmachinestests/Api/Controller/EntityVersionTest.php— Full test coverage
Backend — Modified Files
src/Entity/AuditLog.php— Addversioncolumn + getter/settersrc/Entity/Machine.php— Addversionproperty +getVersion()/incrementVersion()src/EventSubscriber/AbstractAuditSubscriber.php— Auto-increment version + write to AuditLogsrc/EventSubscriber/ComposantAuditSubscriber.php— Enrich snapshot with slots + custom fields + versionsrc/EventSubscriber/PieceAuditSubscriber.php— Enrich snapshot with productSlots + custom fields + versionsrc/EventSubscriber/ProductAuditSubscriber.php— Enrich snapshot with custom fields + versionsrc/EventSubscriber/MachineAuditSubscriber.php— Enrich snapshot with custom fields + versionsrc/Repository/AuditLogRepository.php— AddfindVersionHistory()method
Frontend — New Files
frontend/app/composables/useEntityVersions.ts— API calls for versions/preview/restorefrontend/app/components/common/EntityVersionList.vue— Version list with restore buttonfrontend/app/components/common/VersionRestoreModal.vue— Preview + confirm modal
Frontend — Modified Files
frontend/app/pages/machine/[id].vue— Add Versions sectionfrontend/app/pages/component/[id]/edit.vue— Add Versions sectionfrontend/app/pages/piece/[id].vue— Add Versions sectionfrontend/app/pages/product/[id]/edit.vue— Add Versions section
Task 1: Migration — version columns on audit_logs and machines
Files:
-
Create:
migrations/Version20260325160000.php -
Step 1: Create the migration file
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add version column to audit_logs and machines tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL');
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entitytype, entityid, version)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_audit_entity_version');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN IF EXISTS version');
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS version');
}
}
- Step 2: Run the migration
Run: docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
Expected: Migration applied successfully.
- Step 3: Update test schema
Run: make test-setup
Expected: Schema updated for test environment.
- Step 4: Commit
git add migrations/Version20260325160000.php
git commit -m "feat(versioning) : add version column to audit_logs and machines"
Task 2: AuditLog entity — add version property
Files:
-
Modify:
src/Entity/AuditLog.php -
Step 1: Add the version column property after actorProfileId (line 39)
Add after the $actorProfileId property:
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $version = null;
- Step 2: Add version to constructor parameters
Update the constructor signature to accept version:
public function __construct(
string $entityType,
string $entityId,
string $action,
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
?int $version = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
$this->version = $version;
}
- Step 3: Add getter and setter methods after getCreatedAt()
public function getVersion(): ?int
{
return $this->version;
}
public function setVersion(?int $version): static
{
$this->version = $version;
return $this;
}
- Step 4: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 5: Run tests to verify no regressions
Run: make test
Expected: All tests pass.
- Step 6: Commit
git add src/Entity/AuditLog.php
git commit -m "feat(versioning) : add version property to AuditLog entity"
Task 3: Machine entity — add version property
Files:
-
Modify:
src/Entity/Machine.php -
Step 1: Add version property after updatedAt (line 112)
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
private int $version = 1;
- Step 2: Add getter and incrementVersion methods after getCustomFieldValues()
public function getVersion(): int
{
return $this->version;
}
public function incrementVersion(): static
{
++$this->version;
return $this;
}
- Step 3: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 4: Run tests
Run: make test
Expected: All tests pass (Machine tests now see version column).
- Step 5: Commit
git add src/Entity/Machine.php
git commit -m "feat(versioning) : add version property to Machine entity"
Task 4: AbstractAuditSubscriber — auto-increment version + write to AuditLog
Files:
-
Modify:
src/EventSubscriber/AbstractAuditSubscriber.php -
Step 1: Add a helper method to extract and increment version
Add after the resolveActorProfileId() method (after line 252):
/**
* If the entity has a version, increment it and return the new value.
* Recomputes the changeset so Doctrine picks up the version bump.
*/
protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int
{
if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) {
return null;
}
$entity->incrementVersion();
$uow->recomputeSingleEntityChangeSet(
$em->getClassMetadata($entity::class),
$entity,
);
return $entity->getVersion();
}
/**
* Get the current version without incrementing (for create actions).
*/
protected function getEntityVersion(object $entity): ?int
{
if (!method_exists($entity, 'getVersion')) {
return null;
}
return $entity->getVersion();
}
- Step 2: Modify onFlushSimple — add version to AuditLog on create and update
Replace onFlushSimple() (lines 254-291):
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$version = $this->getEntityVersion($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$version = $this->incrementEntityVersion($entity, $em, $uow);
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
- Step 3: Modify onFlushComplex — add version to AuditLog on create and final persist
Replace onFlushComplex() (lines 293-358):
private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingEntities = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$version = $this->getEntityVersion($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$entityId = (string) $entity->getId();
if ('' === $entityId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$entityId] = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
$pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
$pendingEntities[$entityId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) {
if ([] === $diff) {
continue;
}
$entity = $pendingEntities[$entityId] ?? null;
if (null === $entity || !$this->supports($entity)) {
continue;
}
$version = $this->incrementEntityVersion($entity, $em, $uow);
// Re-take snapshot after version increment so it captures the new version number
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
}
}
- Step 4: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 5: Run tests
Run: make test
Expected: All tests pass. Version auto-increments on update.
- Step 6: Commit
git add src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : auto-increment version on entity updates in audit subscribers"
Task 5: Enrich audit snapshots with slots + custom fields
Files:
-
Modify:
src/EventSubscriber/ComposantAuditSubscriber.php -
Modify:
src/EventSubscriber/PieceAuditSubscriber.php -
Modify:
src/EventSubscriber/ProductAuditSubscriber.php -
Modify:
src/EventSubscriber/MachineAuditSubscriber.php -
Step 1: Enrich ComposantAuditSubscriber snapshotEntity()
Replace snapshotEntity() in ComposantAuditSubscriber.php:
protected function snapshotEntity(object $entity): array
{
$pieceSlots = [];
foreach ($entity->getPieceSlots() as $slot) {
$pieceSlots[] = [
'id' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(),
'position' => $slot->getPosition(),
];
}
$subcomponentSlots = [];
foreach ($entity->getSubcomponentSlots() as $slot) {
$subcomponentSlots[] = [
'id' => $slot->getId(),
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
$productSlots = [];
foreach ($entity->getProductSlots() as $slot) {
$productSlots[] = [
'id' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'description' => $this->safeGet($entity, 'getDescription'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'pieceSlots' => $pieceSlots,
'subcomponentSlots'=> $subcomponentSlots,
'productSlots' => $productSlots,
'customFieldValues'=> $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
- Step 2: Enrich PieceAuditSubscriber snapshotEntity()
Replace snapshotEntity() in PieceAuditSubscriber.php:
protected function snapshotEntity(object $entity): array
{
$productSlots = [];
foreach ($entity->getProductSlots() as $slot) {
$productSlots[] = [
'id' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'description' => $this->safeGet($entity, 'getDescription'),
'prix' => $this->safeGet($entity, 'getPrix'),
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'productSlots' => $productSlots,
'customFieldValues'=> $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
- Step 3: Enrich ProductAuditSubscriber snapshotEntity()
Replace snapshotEntity() in ProductAuditSubscriber.php:
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues'=> $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
- Step 4: Enrich MachineAuditSubscriber snapshotEntity()
Replace snapshotEntity() in MachineAuditSubscriber.php:
protected function snapshotEntity(object $entity): array
{
$customFieldValues = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$customFieldValues[] = [
'id' => $cfv->getId(),
'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(),
'value' => $cfv->getValue(),
];
}
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'reference' => $this->safeGet($entity, 'getReference'),
'prix' => $this->safeGet($entity, 'getPrix'),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'customFieldValues'=> $customFieldValues,
'version' => $this->safeGet($entity, 'getVersion'),
];
}
- Step 5: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 6: Run tests
Run: make test
Expected: All tests pass. Snapshots now include enriched data.
- Step 7: Commit
git add src/EventSubscriber/ComposantAuditSubscriber.php src/EventSubscriber/PieceAuditSubscriber.php src/EventSubscriber/ProductAuditSubscriber.php src/EventSubscriber/MachineAuditSubscriber.php
git commit -m "feat(versioning) : enrich audit snapshots with slots, custom fields and version"
Task 6: AuditLogRepository — add findVersionHistory()
Files:
-
Modify:
src/Repository/AuditLogRepository.php -
Step 1: Add findVersionHistory method
Add after findEntityHistory():
/**
* @return list<AuditLog>
*/
public function findVersionHistory(string $entityType, string $entityId): array
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->andWhere('a.version IS NOT NULL')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->orderBy('a.version', 'DESC')
->getQuery()
->getResult()
;
}
public function findByVersion(string $entityType, string $entityId, int $version): ?AuditLog
{
return $this->createQueryBuilder('a')
->andWhere('a.entityType = :entityType')
->andWhere('a.entityId = :entityId')
->andWhere('a.version = :version')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->setParameter('version', $version)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
- Step 2: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 3: Commit
git add src/Repository/AuditLogRepository.php
git commit -m "feat(versioning) : add findVersionHistory and findByVersion to AuditLogRepository"
Task 7: EntityVersionService — core restore logic
Files:
-
Create:
src/Service/EntityVersionService.php -
Step 1: Create the service
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\ConstructeurRepository;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use App\Repository\ModelTypeRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use App\Repository\SiteRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
final class EntityVersionService
{
private const ENTITY_MAP = [
'machine' => Machine::class,
'composant' => Composant::class,
'piece' => Piece::class,
'product' => Product::class,
];
private const BASE_FIELDS = [
'machine' => ['name', 'reference', 'prix'],
'composant' => ['name', 'reference', 'description', 'prix'],
'piece' => ['name', 'reference', 'description', 'prix'],
'product' => ['name', 'reference', 'supplierPrice'],
];
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
private readonly MachineRepository $machines,
private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces,
private readonly ProductRepository $products,
private readonly ConstructeurRepository $constructeurs,
private readonly SiteRepository $sites,
private readonly ModelTypeRepository $modelTypes,
private readonly CustomFieldValueRepository $customFieldValues,
private readonly ProfileRepository $profiles,
) {}
/**
* @return array{items: list<array>, total: int}
*/
public function getVersions(string $entityType, string $entityId): array
{
$logs = $this->auditLogs->findVersionHistory($entityType, $entityId);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn (AuditLog $log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ([] !== $actorIds) {
$profileEntities = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profileEntities 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 (AuditLog $log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'version' => $log->getVersion(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? ['id' => $actorId, 'label' => $actorMap[$actorId] ?? $actorId]
: null,
'diff' => $log->getDiff(),
];
},
$logs,
);
return ['items' => array_values($items), 'total' => count($items)];
}
/**
* @return array{version: int, restoreMode: string, diff: array, warnings: list<array>, snapshot: array}
*/
public function getRestorePreview(string $entityType, string $entityId, int $version): array
{
$entity = $this->findEntity($entityType, $entityId);
if (null === $entity) {
throw new \InvalidArgumentException('Entité introuvable.');
}
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
if (null === $auditLog) {
throw new \InvalidArgumentException('Version introuvable.');
}
$snapshot = $auditLog->getSnapshot() ?? [];
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$diff = $this->buildRestoreDiff($entityType, $entity, $snapshot, $restoreMode);
return [
'version' => $version,
'restoreMode' => $restoreMode,
'diff' => $diff,
'warnings' => $warnings,
'snapshot' => $snapshot,
];
}
/**
* @return array{success: true, newVersion: int, restoredFromVersion: int, restoreMode: string, warnings: list<array>}
*/
public function restore(string $entityType, string $entityId, int $version): array
{
$entity = $this->findEntity($entityType, $entityId);
if (null === $entity) {
throw new \InvalidArgumentException('Entité introuvable.');
}
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
if (null === $auditLog) {
throw new \InvalidArgumentException('Version introuvable.');
}
$snapshot = $auditLog->getSnapshot() ?? [];
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$connection = $this->em->getConnection();
$connection->beginTransaction();
try {
// Mark entity to skip audit subscriber (we create the AuditLog manually)
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(true);
}
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
// Increment version manually (since subscriber is skipped)
if (method_exists($entity, 'incrementVersion')) {
$entity->incrementVersion();
}
$this->em->flush();
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
// Create the restore AuditLog manually with action = "restore"
$restoreAuditLog = new AuditLog(
$entityType,
$entityId,
'restore',
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
$this->buildCurrentSnapshot($entityType, $entity),
$this->resolveActorProfileId(),
$newVersion,
);
$this->em->persist($restoreAuditLog);
$this->em->flush();
$connection->commit();
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
} finally {
// Clear skip flag
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(false);
}
}
return [
'success' => true,
'newVersion' => $newVersion,
'restoredFromVersion' => $version,
'restoreMode' => $restoreMode,
'warnings' => $warnings,
];
}
private function findEntity(string $entityType, string $entityId): ?object
{
return match ($entityType) {
'machine' => $this->machines->find($entityId),
'composant' => $this->composants->find($entityId),
'piece' => $this->pieces->find($entityId),
'product' => $this->products->find($entityId),
default => null,
};
}
private function checkSkeletonCompatibility(string $entityType, object $entity, array $snapshot): string
{
if ('machine' === $entityType) {
return 'full';
}
$currentTypeId = match ($entityType) {
'composant' => $entity->getTypeComposant()?->getId(),
'piece' => $entity->getTypePiece()?->getId(),
'product' => $entity->getTypeProduct()?->getId(),
default => null,
};
$typeKey = match ($entityType) {
'composant' => 'typeComposant',
'piece' => 'typePiece',
'product' => 'typeProduct',
default => null,
};
$snapshotTypeId = null;
if ($typeKey && isset($snapshot[$typeKey])) {
$snapshotTypeId = is_array($snapshot[$typeKey]) ? ($snapshot[$typeKey]['id'] ?? null) : $snapshot[$typeKey];
}
return $currentTypeId === $snapshotTypeId ? 'full' : 'partial';
}
private function checkIntegrity(string $entityType, array $snapshot, string $restoreMode): array
{
$warnings = [];
// Check constructeurs (batch query)
if (!empty($snapshot['constructeurIds'])) {
$constructeurEntries = [];
foreach ($snapshot['constructeurIds'] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
$name = is_array($entry) ? ($entry['name'] ?? $id) : $id;
if ($id) {
$constructeurEntries[$id] = $name;
}
}
if ([] !== $constructeurEntries) {
$foundIds = array_map(
fn ($c) => $c->getId(),
$this->constructeurs->findBy(['id' => array_keys($constructeurEntries)]),
);
foreach ($constructeurEntries as $id => $name) {
if (!in_array($id, $foundIds, true)) {
$warnings[] = [
'field' => 'constructeurIds',
'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name),
'missingEntityId' => $id,
'missingEntityName' => $name,
];
}
}
}
}
// Machine: check site
if ('machine' === $entityType && !empty($snapshot['site'])) {
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
if ($siteId && null === $this->sites->find($siteId)) {
$siteName = is_array($snapshot['site']) ? ($snapshot['site']['name'] ?? $siteId) : $siteId;
$warnings[] = [
'field' => 'site',
'message' => sprintf("Le site '%s' n'existe plus. Le site actuel sera conservé.", $siteName),
'missingEntityId' => $siteId,
'missingEntityName' => $siteName,
];
}
}
if ('partial' === $restoreMode) {
return $warnings;
}
// Full mode: check slot references (batch queries per slot type)
$slotChecks = match ($entityType) {
'composant' => [
['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces],
['key' => 'subcomponentSlots', 'refKey' => 'selectedComposantId', 'label' => 'sous-composant', 'repo' => $this->composants],
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
],
'piece' => [
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
],
default => [],
};
foreach ($slotChecks as $check) {
$slots = $snapshot[$check['key']] ?? [];
// Collect all referenced IDs for batch lookup
$refIds = [];
foreach ($slots as $i => $slot) {
$refId = $slot[$check['refKey']] ?? null;
if (null !== $refId) {
$refIds[$i] = $refId;
}
}
if ([] === $refIds) {
continue;
}
$foundEntities = $check['repo']->findBy(['id' => array_values(array_unique($refIds))]);
$foundIds = array_map(fn ($e) => $e->getId(), $foundEntities);
foreach ($refIds as $i => $refId) {
if (!in_array($refId, $foundIds, true)) {
$warnings[] = [
'field' => sprintf('%s[%d].%s', $check['key'], $i, $check['refKey']),
'message' => sprintf("Le %s sélectionné dans le slot n'existe plus. Le slot sera restauré vide.", $check['label']),
'missingEntityId' => $refId,
'missingEntityName' => null,
];
}
}
}
return $warnings;
}
private function buildRestoreDiff(string $entityType, object $entity, array $snapshot, string $restoreMode): array
{
$diff = [];
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$getter = 'get'.ucfirst($field);
if (!method_exists($entity, $getter)) {
continue;
}
$current = $entity->{$getter}();
$restored = $snapshot[$field] ?? null;
if ((string) ($current ?? '') !== (string) ($restored ?? '')) {
$diff[$field] = ['current' => $current, 'restored' => $restored];
}
}
// Constructeurs
$currentConstructeurIds = [];
if (method_exists($entity, 'getConstructeurs')) {
foreach ($entity->getConstructeurs() as $c) {
$currentConstructeurIds[] = $c->getId();
}
}
$snapshotConstructeurIds = [];
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
$snapshotConstructeurIds[] = is_array($entry) ? ($entry['id'] ?? null) : $entry;
}
sort($currentConstructeurIds);
sort($snapshotConstructeurIds);
if ($currentConstructeurIds !== $snapshotConstructeurIds) {
$diff['constructeurIds'] = ['current' => $currentConstructeurIds, 'restored' => $snapshotConstructeurIds];
}
if ('full' === $restoreMode) {
// We signal slot restore as a single diff entry
$slotKeys = match ($entityType) {
'composant' => ['pieceSlots', 'subcomponentSlots', 'productSlots'],
'piece' => ['productSlots'],
default => [],
};
foreach ($slotKeys as $key) {
if (!empty($snapshot[$key])) {
$diff[$key] = ['current' => '(structure actuelle)', 'restored' => sprintf('%d slot(s)', count($snapshot[$key]))];
}
}
}
return $diff;
}
private function applyRestore(string $entityType, object $entity, array $snapshot, string $restoreMode): void
{
// Restore base fields
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$setter = 'set'.ucfirst($field);
if (method_exists($entity, $setter) && array_key_exists($field, $snapshot)) {
$entity->{$setter}($snapshot[$field]);
}
}
// Restore constructeurs
$this->restoreConstructeurs($entity, $snapshot);
// Machine: restore site
if ('machine' === $entityType && isset($snapshot['site'])) {
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
if ($siteId) {
$site = $this->sites->find($siteId);
if (null !== $site) {
$entity->setSite($site);
}
}
}
if ('partial' === $restoreMode) {
return;
}
// Full mode: restore slots
match ($entityType) {
'composant' => $this->restoreComposantSlots($entity, $snapshot),
'piece' => $this->restorePieceSlots($entity, $snapshot),
default => null,
};
// Full mode: restore custom field values
$this->restoreCustomFieldValues($entityType, $entity, $snapshot);
}
private function restoreConstructeurs(object $entity, array $snapshot): void
{
if (!method_exists($entity, 'getConstructeurs') || !method_exists($entity, 'addConstructeur') || !method_exists($entity, 'removeConstructeur')) {
return;
}
$targetIds = [];
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
if ($id) {
$targetIds[] = $id;
}
}
// Remove current constructeurs not in snapshot
foreach ($entity->getConstructeurs()->toArray() as $c) {
if (!in_array($c->getId(), $targetIds, true)) {
$entity->removeConstructeur($c);
}
}
// Add missing constructeurs from snapshot
$currentIds = array_map(fn ($c) => $c->getId(), $entity->getConstructeurs()->toArray());
foreach ($targetIds as $id) {
if (!in_array($id, $currentIds, true)) {
$constructeur = $this->constructeurs->find($id);
if (null !== $constructeur) {
$entity->addConstructeur($constructeur);
}
}
}
}
private function restoreComposantSlots(Composant $entity, array $snapshot): void
{
// Clear existing slots
foreach ($entity->getPieceSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
foreach ($entity->getSubcomponentSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
foreach ($entity->getProductSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts with new slots
$this->em->flush();
// Recreate piece slots
foreach ($snapshot['pieceSlots'] ?? [] as $slotData) {
$slot = new ComposantPieceSlot();
$slot->setComposant($entity);
$slot->setQuantity($slotData['quantity'] ?? 1);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typePieceId'])) {
$slot->setTypePiece($this->modelTypes->find($slotData['typePieceId']));
}
if (!empty($slotData['selectedPieceId'])) {
$piece = $this->pieces->find($slotData['selectedPieceId']);
if (null !== $piece) {
$slot->setSelectedPiece($piece);
}
}
$this->em->persist($slot);
}
// Recreate subcomponent slots
foreach ($snapshot['subcomponentSlots'] ?? [] as $slotData) {
$slot = new ComposantSubcomponentSlot();
$slot->setComposant($entity);
$slot->setAlias($slotData['alias'] ?? null);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeComposantId'])) {
$slot->setTypeComposant($this->modelTypes->find($slotData['typeComposantId']));
}
if (!empty($slotData['selectedComposantId'])) {
$composant = $this->composants->find($slotData['selectedComposantId']);
if (null !== $composant) {
$slot->setSelectedComposant($composant);
}
}
$this->em->persist($slot);
}
// Recreate product slots
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
$slot = new ComposantProductSlot();
$slot->setComposant($entity);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeProductId'])) {
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
}
if (!empty($slotData['selectedProductId'])) {
$product = $this->products->find($slotData['selectedProductId']);
if (null !== $product) {
$slot->setSelectedProduct($product);
}
}
$this->em->persist($slot);
}
}
private function restorePieceSlots(Piece $entity, array $snapshot): void
{
foreach ($entity->getProductSlots()->toArray() as $slot) {
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts
$this->em->flush();
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
$slot = new PieceProductSlot();
$slot->setPiece($entity);
$slot->setFamilyCode($slotData['familyCode'] ?? null);
$slot->setPosition($slotData['position'] ?? 0);
if (!empty($slotData['typeProductId'])) {
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
}
if (!empty($slotData['selectedProductId'])) {
$product = $this->products->find($slotData['selectedProductId']);
if (null !== $product) {
$slot->setSelectedProduct($product);
}
}
$this->em->persist($slot);
}
}
private function restoreCustomFieldValues(string $entityType, object $entity, array $snapshot): void
{
$snapshotCfvs = $snapshot['customFieldValues'] ?? [];
if ([] === $snapshotCfvs) {
return;
}
// Build a map of current CFVs by fieldId for lookup
$currentCfvsByFieldId = [];
if (method_exists($entity, 'getCustomFieldValues')) {
foreach ($entity->getCustomFieldValues() as $cfv) {
$fieldId = $cfv->getCustomField()?->getId();
if (null !== $fieldId) {
$currentCfvsByFieldId[$fieldId] = $cfv;
}
}
}
foreach ($snapshotCfvs as $cfvData) {
$fieldId = $cfvData['fieldId'] ?? null;
if (!$fieldId) {
continue;
}
// Try to find the current CFV by fieldId (resilient to ID changes after sync)
$cfv = $currentCfvsByFieldId[$fieldId] ?? null;
// Fallback: try by original ID if fieldId lookup failed
if (null === $cfv && !empty($cfvData['id'])) {
$cfv = $this->customFieldValues->find($cfvData['id']);
}
if (null !== $cfv) {
$cfv->setValue($cfvData['value'] ?? null);
}
}
}
/**
* Build a complete snapshot of the entity in its current state (after restore).
* Must be consistent with the snapshots built by the audit subscribers,
* so that a future restore from a "restore" version works correctly.
*/
private function buildCurrentSnapshot(string $entityType, object $entity): array
{
$snapshot = ['id' => $entity->getId()];
// Base fields
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$getter = 'get'.ucfirst($field);
if (method_exists($entity, $getter)) {
$snapshot[$field] = $entity->{$getter}();
}
}
// Version
if (method_exists($entity, 'getVersion')) {
$snapshot['version'] = $entity->getVersion();
}
// Constructeurs
if (method_exists($entity, 'getConstructeurs')) {
$snapshot['constructeurIds'] = [];
foreach ($entity->getConstructeurs() as $c) {
$snapshot['constructeurIds'][] = ['id' => $c->getId(), 'name' => $c->getName()];
}
}
// Type (ModelType reference)
$typeGetters = ['composant' => 'getTypeComposant', 'piece' => 'getTypePiece', 'product' => 'getTypeProduct'];
$typeKeys = ['composant' => 'typeComposant', 'piece' => 'typePiece', 'product' => 'typeProduct'];
if (isset($typeGetters[$entityType]) && method_exists($entity, $typeGetters[$entityType])) {
$type = $entity->{$typeGetters[$entityType]}();
$snapshot[$typeKeys[$entityType]] = $type ? ['id' => $type->getId(), 'name' => $type->getName(), 'code' => $type->getCode()] : null;
}
// Machine: site
if ('machine' === $entityType && method_exists($entity, 'getSite')) {
$site = $entity->getSite();
$snapshot['site'] = $site ? ['id' => $site->getId(), 'name' => $site->getName()] : null;
}
// Composant/Piece: product
if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) {
$product = $entity->getProduct();
$snapshot['product'] = $product ? ['id' => $product->getId(), 'name' => $product->getName()] : null;
}
// Slots
if ('composant' === $entityType) {
$snapshot['pieceSlots'] = [];
foreach ($entity->getPieceSlots() as $slot) {
$snapshot['pieceSlots'][] = [
'id' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(), 'position' => $slot->getPosition(),
];
}
$snapshot['subcomponentSlots'] = [];
foreach ($entity->getSubcomponentSlots() as $slot) {
$snapshot['subcomponentSlots'][] = [
'id' => $slot->getId(), 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
$snapshot['productSlots'] = [];
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
}
if ('piece' === $entityType) {
$snapshot['productSlots'] = [];
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
}
// Custom field values
if (method_exists($entity, 'getCustomFieldValues')) {
$snapshot['customFieldValues'] = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$snapshot['customFieldValues'][] = [
'id' => $cfv->getId(), 'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(), 'value' => $cfv->getValue(),
];
}
}
return $snapshot;
}
/**
* Resolve the current actor profile ID from the session.
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
*/
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
} catch (\Throwable) {
// No session available (CLI context, etc.)
}
return null;
}
}
- Step 2: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 3: Commit
git add src/Service/EntityVersionService.php
git commit -m "feat(versioning) : add EntityVersionService with restore logic"
Task 7b: skipAudit flag on entities + subscriber check
The restore() method creates its own AuditLog with action = "restore". The audit subscribers must skip entities flagged with skipAudit = true to avoid a duplicate update AuditLog.
Files:
-
Modify:
src/Entity/Machine.php,src/Entity/Composant.php,src/Entity/Piece.php,src/Entity/Product.php -
Modify:
src/EventSubscriber/AbstractAuditSubscriber.php -
Step 1: Add skipAudit flag to each entity
Add to Machine, Composant, Piece, Product (transient property, NOT mapped to DB):
/**
* Transient flag — when true, audit subscribers skip this entity.
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
*/
private bool $skipAudit = false;
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
- Step 2: Add skipAudit check in AbstractAuditSubscriber
In onFlush() method, add an early return that scans all scheduled entities for the skipAudit flag. This covers ALL paths (simple, complex, collections, CFV changes) and avoids any duplicate AuditLogs:
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
// If any tracked entity has skipAudit=true, skip the entire subscriber.
// This is set by EntityVersionService::restore() to avoid duplicate audit logs.
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) {
return;
}
}
$actorProfileId = $this->resolveActorProfileId();
$entityType = $this->entityType();
if ($this->hasCollectionTracking()) {
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
} else {
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
}
}
This replaces the existing onFlush() method. The check is at the top level so it covers entity updates, collection changes, and custom field value changes — all paths that onFlushComplex processes.
- Step 3: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 4: Run tests
Run: make test
Expected: All tests pass.
- Step 5: Commit
git add src/Entity/Machine.php src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : add skipAudit flag for restore-originated flushes"
Task 8: EntityVersionController — REST endpoints
Files:
-
Create:
src/Controller/EntityVersionController.php -
Step 1: Create the controller
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Service\EntityVersionService;
use Doctrine\ORM\EntityRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EntityVersionController extends AbstractController
{
/** @var array<string, array{repo: EntityRepository<object>, label: string}> */
private readonly array $entityConfig;
public function __construct(
MachineRepository $machines,
PieceRepository $pieces,
ComposantRepository $composants,
ProductRepository $products,
private readonly EntityVersionService $versionService,
) {
$this->entityConfig = [
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
];
}
// ── Versions list ───────────────────────────────────────────────
#[Route('/api/machines/{id}/versions', name: 'api_machine_versions', methods: ['GET'])]
public function machineVersions(string $id): JsonResponse
{
return $this->listVersions('machine', $id);
}
#[Route('/api/composants/{id}/versions', name: 'api_composant_versions', methods: ['GET'])]
public function composantVersions(string $id): JsonResponse
{
return $this->listVersions('composant', $id);
}
#[Route('/api/pieces/{id}/versions', name: 'api_piece_versions', methods: ['GET'])]
public function pieceVersions(string $id): JsonResponse
{
return $this->listVersions('piece', $id);
}
#[Route('/api/products/{id}/versions', name: 'api_product_versions', methods: ['GET'])]
public function productVersions(string $id): JsonResponse
{
return $this->listVersions('product', $id);
}
// ── Preview ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/preview', name: 'api_machine_version_preview', methods: ['GET'])]
public function machineVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/preview', name: 'api_composant_version_preview', methods: ['GET'])]
public function composantVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/preview', name: 'api_piece_version_preview', methods: ['GET'])]
public function pieceVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/preview', name: 'api_product_version_preview', methods: ['GET'])]
public function productVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('product', $id, $version);
}
// ── Restore ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/restore', name: 'api_machine_version_restore', methods: ['POST'])]
public function machineVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/restore', name: 'api_composant_version_restore', methods: ['POST'])]
public function composantVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/restore', name: 'api_piece_version_restore', methods: ['POST'])]
public function pieceVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/restore', name: 'api_product_version_restore', methods: ['POST'])]
public function productVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('product', $id, $version);
}
// ── Private helpers ─────────────────────────────────────────────
private function listVersions(string $entityType, string $entityId): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$config = $this->entityConfig[$entityType];
if (!$config['repo']->find($entityId)) {
return new JsonResponse(['message' => $config['label']], Response::HTTP_NOT_FOUND);
}
return new JsonResponse($this->versionService->getVersions($entityType, $entityId));
}
private function preview(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->getRestorePreview($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (\InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
private function restoreVersion(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->restore($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (\InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
}
- Step 2: Run php-cs-fixer
Run: make php-cs-fixer-allow-risky
- Step 3: Commit
git add src/Controller/EntityVersionController.php
git commit -m "feat(versioning) : add EntityVersionController with list, preview and restore endpoints"
Task 9: Backend tests
Files:
-
Create:
tests/Api/Controller/EntityVersionTest.php -
Step 1: Create the test file
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class EntityVersionTest extends AbstractApiTestCase
{
// ── Versions list ───────────────────────────────────────────────
public function testMachineVersionsAfterCreateAndUpdate(): void
{
$machine = $this->createMachine('Machine V');
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Machine V Updated'],
]);
$this->assertResponseIsSuccessful();
$vClient = $this->createViewerClient();
$vClient->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
$this->assertResponseIsSuccessful();
$data = $vClient->getResponse()->toArray();
$this->assertArrayHasKey('items', $data);
$this->assertArrayHasKey('total', $data);
$this->assertGreaterThanOrEqual(1, $data['total']);
$firstItem = $data['items'][0];
$this->assertArrayHasKey('version', $firstItem);
$this->assertArrayHasKey('action', $firstItem);
$this->assertArrayHasKey('createdAt', $firstItem);
}
public function testComposantVersionsList(): void
{
$composant = $this->createComposant('Composant V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/composants/%s/versions', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertArrayHasKey('items', $data);
}
public function testPieceVersionsList(): void
{
$piece = $this->createPiece('Piece V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/pieces/%s/versions', $piece->getId()));
$this->assertResponseIsSuccessful();
}
public function testProductVersionsList(): void
{
$product = $this->createProduct('Product V');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/products/%s/versions', $product->getId()));
$this->assertResponseIsSuccessful();
}
public function testVersionsNotFound(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/machines/nonexistent-id/versions');
$this->assertResponseStatusCodeSame(404);
}
public function testVersionsUnauthenticated(): void
{
$machine = $this->createMachine('Machine V');
$client = $this->createUnauthenticatedClient();
$client->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
$this->assertResponseStatusCodeSame(401);
}
// ── Preview ─────────────────────────────────────────────────────
public function testPreviewRequiresGestionnaire(): void
{
$machine = $this->createMachine('Machine P');
$client = $this->createViewerClient();
$client->request('GET', sprintf('/api/machines/%s/versions/1/preview', $machine->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testPreviewReturnsRestoreInfo(): void
{
$composant = $this->createComposant('Composant P');
// Update to create version 2
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Composant P Updated'],
]);
$this->assertResponseIsSuccessful();
// Preview restore to version 1
$gClient2 = $this->createGestionnaireClient();
$gClient2->request('GET', sprintf('/api/composants/%s/versions/1/preview', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $gClient2->getResponse()->toArray();
$this->assertArrayHasKey('version', $data);
$this->assertArrayHasKey('restoreMode', $data);
$this->assertArrayHasKey('diff', $data);
$this->assertArrayHasKey('warnings', $data);
$this->assertEquals(1, $data['version']);
$this->assertEquals('full', $data['restoreMode']);
}
// ── Restore ─────────────────────────────────────────────────────
public function testRestoreRequiresGestionnaire(): void
{
$machine = $this->createMachine('Machine R');
$client = $this->createViewerClient();
$client->request('POST', sprintf('/api/machines/%s/versions/1/restore', $machine->getId()));
$this->assertResponseStatusCodeSame(403);
}
public function testRestoreCreatesNewVersion(): void
{
$composant = $this->createComposant('Original Name');
// Update
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Updated Name'],
]);
$this->assertResponseIsSuccessful();
// Restore to version 1
$gClient2 = $this->createGestionnaireClient();
$gClient2->request('POST', sprintf('/api/composants/%s/versions/1/restore', $composant->getId()));
$this->assertResponseIsSuccessful();
$data = $gClient2->getResponse()->toArray();
$this->assertTrue($data['success']);
$this->assertEquals(1, $data['restoredFromVersion']);
$this->assertGreaterThan(2, $data['newVersion']);
// Verify the name was restored
$vClient = $this->createViewerClient();
$vClient->request('GET', self::iri('composants', $composant->getId()));
$this->assertResponseIsSuccessful();
$entityData = $vClient->getResponse()->toArray();
$this->assertEquals('Original Name', $entityData['name']);
}
public function testRestoreVersionNotFound(): void
{
$composant = $this->createComposant('Composant NF');
$client = $this->createGestionnaireClient();
$client->request('POST', sprintf('/api/composants/%s/versions/999/restore', $composant->getId()));
$this->assertResponseStatusCodeSame(404);
}
}
- Step 2: Run tests
Run: make test FILES=tests/Api/Controller/EntityVersionTest.php
Expected: All tests pass.
- Step 3: Run full test suite
Run: make test
Expected: All tests pass including existing tests.
- Step 4: Commit
git add tests/Api/Controller/EntityVersionTest.php
git commit -m "test(versioning) : add EntityVersionTest for list, preview and restore"
Task 9b: Frontend — add restore action label to historyDisplayUtils
Files:
-
Modify:
frontend/app/shared/utils/historyDisplayUtils.ts -
Step 1: Add
restorecase tohistoryActionLabel
In historyDisplayUtils.ts, update historyActionLabel:
export const historyActionLabel = (action: string): string => {
if (action === 'create') return 'Création'
if (action === 'delete') return 'Suppression'
if (action === 'restore') return 'Restauration'
return 'Modification'
}
- Step 2: Commit in frontend repo
cd frontend
git add app/shared/utils/historyDisplayUtils.ts
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
cd ..
Task 10: Frontend — useEntityVersions composable
Files:
-
Create:
frontend/app/composables/useEntityVersions.ts -
Step 1: Create the composable
import { ref, toValue } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'
export interface VersionEntry {
version: number
action: 'create' | 'update' | 'restore' | string
createdAt: string
actor: { id: string; label: string } | null
diff: Record<string, { from: unknown; to: unknown }> | null
}
export interface RestorePreview {
version: number
restoreMode: 'full' | 'partial'
diff: Record<string, { current: unknown; restored: unknown }>
warnings: Array<{
field: string
message: string
missingEntityId: string | null
missingEntityName: string | null
}>
snapshot: Record<string, unknown>
}
export interface RestoreResult {
success: boolean
newVersion: number
restoredFromVersion: number
restoreMode: 'full' | 'partial'
warnings: RestorePreview['warnings']
}
const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants',
piece: '/pieces',
product: '/products',
}
interface Deps {
entityType: MaybeRef<string>
entityId: MaybeRef<string>
}
export function useEntityVersions(deps: Deps) {
const { get, post } = useApi()
const versions = ref<VersionEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const getPath = () => {
const type = toValue(deps.entityType)
const id = toValue(deps.entityId)
const base = ENTITY_ENDPOINTS[type]
return `${base}/${id}`
}
const fetchVersions = async () => {
loading.value = true
error.value = null
try {
const result = await get(`${getPath()}/versions`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les versions.'
versions.value = []
return
}
versions.value = result.data?.items ?? []
} catch (err: any) {
error.value = err?.message ?? 'Erreur inconnue'
versions.value = []
} finally {
loading.value = false
}
}
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
if (!result.success || !result.data) {
return null
}
return result.data
}
const restore = async (version: number): Promise<RestoreResult | null> => {
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
if (!result.success || !result.data) {
return null
}
return result.data
}
return { versions, loading, error, fetchVersions, fetchPreview, restore }
}
- Step 2: Run lint
Run (in frontend/): npm run lint:fix
- Step 3: Run typecheck
Run (in frontend/): npx nuxi typecheck
- Step 4: Commit in frontend repo
cd frontend
git add app/composables/useEntityVersions.ts
git commit -m "feat(versioning) : add useEntityVersions composable"
cd ..
Task 11: Frontend — VersionRestoreModal component
Files:
-
Create:
frontend/app/components/common/VersionRestoreModal.vue -
Step 1: Create the modal component
<template>
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
<div class="modal-box max-w-lg">
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
<div v-if="!preview" class="flex justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<template v-else>
<div class="mt-4 space-y-4">
<!-- Restore mode -->
<div
class="alert text-sm"
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
>
<span v-if="preview.restoreMode === 'full'">
Restauration complète — le squelette est identique.
</span>
<span v-else>
Restauration partielle — le squelette a changé. Seuls les champs de base seront restaurés.
</span>
</div>
<!-- Diff -->
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
<h4 class="text-sm font-semibold">Changements à appliquer</h4>
<ul class="space-y-1 text-sm">
<li
v-for="(change, field) in preview.diff"
:key="field"
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
>
<span class="font-medium text-base-content">{{ fieldLabels[field] || field }}</span>
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
</li>
</ul>
</div>
<!-- Warnings -->
<div v-if="preview.warnings.length" class="space-y-1">
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
<ul class="space-y-1">
<li
v-for="(warning, i) in preview.warnings"
:key="i"
class="alert alert-warning text-xs py-2"
>
{{ warning.message }}
</li>
</ul>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
Annuler
</button>
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
Confirmer la restauration
</button>
</div>
</template>
</div>
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
<button type="button">close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import type { RestorePreview } from '~/composables/useEntityVersions'
defineProps<{
visible: boolean
preview: RestorePreview | null
restoring: boolean
fieldLabels: Record<string, string>
}>()
defineEmits<{
close: []
confirm: []
}>()
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
</script>
- Step 2: Run lint
Run (in frontend/): npm run lint:fix
- Step 3: Commit
cd frontend
git add app/components/common/VersionRestoreModal.vue
git commit -m "feat(versioning) : add VersionRestoreModal component"
cd ..
Task 12: Frontend — EntityVersionList component
Files:
-
Create:
frontend/app/components/common/EntityVersionList.vue -
Step 1: Create the version list component
<template>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Versions</h2>
<p class="text-xs text-base-content/70">
Historique des versions avec possibilité de restauration.
</p>
</div>
<span v-if="versions.length" class="badge badge-outline">
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des versions…
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
Aucune version enregistrée.
</p>
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in versions"
:key="entry.version"
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
<span
v-if="entry.version === currentVersion"
class="badge badge-primary badge-sm"
>
actuelle
</span>
<span
v-if="entry.action === 'restore'"
class="badge badge-warning badge-sm"
>
restauration
</span>
</div>
<div class="flex flex-wrap items-center gap-2 mt-0.5 text-xs text-base-content/60">
<span>{{ actionLabel(entry.action) }}</span>
<span>·</span>
<span>{{ formatDate(entry.createdAt) }}</span>
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
</div>
</div>
<button
v-if="canRestore && entry.version !== currentVersion"
class="btn btn-ghost btn-xs"
:disabled="restoring"
@click="handleRestore(entry.version)"
>
Restaurer
</button>
</li>
</ul>
<VersionRestoreModal
:visible="modalVisible"
:preview="previewData"
:restoring="restoring"
:field-labels="fieldLabels"
@close="modalVisible = false"
@confirm="confirmRestore"
/>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
import { usePermissions } from '~/composables/usePermissions'
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
import VersionRestoreModal from './VersionRestoreModal.vue'
const props = defineProps<{
entityType: 'machine' | 'composant' | 'piece' | 'product'
entityId: string
fieldLabels: Record<string, string>
}>()
const emit = defineEmits<{
restored: []
}>()
const { canEdit } = usePermissions()
const canRestore = computed(() => canEdit.value)
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
entityType: props.entityType,
entityId: props.entityId,
})
const currentVersion = computed(() => {
if (versions.value.length === 0) return null
return versions.value[0]?.version ?? null
})
const modalVisible = ref(false)
const previewData = ref<RestorePreview | null>(null)
const restoring = ref(false)
const targetVersion = ref<number | null>(null)
const actionLabel = (action: string) => historyActionLabel(action)
const formatDate = (date: string) => formatHistoryDate(date)
const handleRestore = async (version: number) => {
targetVersion.value = version
previewData.value = null
modalVisible.value = true
previewData.value = await fetchPreview(version)
}
const confirmRestore = async () => {
if (!targetVersion.value) return
restoring.value = true
const result = await restore(targetVersion.value)
restoring.value = false
if (result?.success) {
modalVisible.value = false
await fetchVersions()
emit('restored')
} else {
error.value = 'La restauration a échoué.'
modalVisible.value = false
}
}
onMounted(() => {
fetchVersions()
})
</script>
- Step 2: Run lint
Run (in frontend/): npm run lint:fix
- Step 3: Commit
cd frontend
git add app/components/common/EntityVersionList.vue
git commit -m "feat(versioning) : add EntityVersionList component with restore flow"
cd ..
Task 13: Frontend — Integrate EntityVersionList into detail pages
Files:
-
Modify:
frontend/app/pages/machine/[id].vue -
Modify:
frontend/app/pages/component/[id]/edit.vue -
Modify:
frontend/app/pages/piece/[id].vue -
Modify:
frontend/app/pages/product/[id]/edit.vue -
Step 1: Add EntityVersionList to machine/[id].vue
In machine/[id].vue, add after the EntityHistorySection block (after line 147) and before the Comments section:
<!-- Versions -->
<EntityVersionList
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
@restored="d.loadMachineData()"
/>
Add the import at the top of <script setup>:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
- Step 2: Add EntityVersionList to component/[id]/edit.vue
In component/[id]/edit.vue, add after the EntityHistorySection block (after line 317) and before the save buttons:
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="fetchComponent()"
/>
Add the import:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
- Step 3: Add EntityVersionList to piece/[id].vue
In piece/[id].vue, add after the EntityHistorySection block (after line 330) and before the save buttons:
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="fetchPiece()"
/>
Add the import:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
- Step 4: Add EntityVersionList to product/[id]/edit.vue
In product/[id]/edit.vue, add after the EntityHistorySection block and before the save buttons:
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="loadProduct()"
/>
Where loadProduct is the existing function that calls getProduct(id) and populates the form. If no such function is exposed, extract the onMounted data-loading logic into a named function that can be called from @restored.
Add the import:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
- Step 5: Run lint
Run (in frontend/): npm run lint:fix
- Step 6: Run typecheck
Run (in frontend/): npx nuxi typecheck
- Step 7: Commit in frontend repo
cd frontend
git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue
git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages"
cd ..
- Step 8: Update submodule pointer in main repo
git add frontend
git commit -m "chore(submodule) : update frontend pointer (entity versioning)"
Task 14: Final verification
- Step 1: Run backend tests
Run: make test
Expected: All tests pass.
- Step 2: Run frontend lint + typecheck
Run (in frontend/): npm run lint:fix && npx nuxi typecheck
Expected: 0 errors.
- Step 3: Manual smoke test
- Create a composant → check versions list shows v1
- Update composant name → versions list shows v2
- Click "Restaurer" on v1 → preview shows diff + "full" mode
- Confirm → name reverts, v3 created
- Repeat for piece, product, machine
- Step 4: Update CLAUDE.md if needed
Add EntityVersionController to the Custom Controllers section and note the version auto-increment behavior.