# 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` — Add `version` column to `audit_logs` and `machines` - `tests/Api/Controller/EntityVersionTest.php` — Full test coverage ### Backend — Modified Files - `src/Entity/AuditLog.php` — Add `version` column + getter/setter - `src/Entity/Machine.php` — Add `version` property + `getVersion()`/`incrementVersion()` - `src/EventSubscriber/AbstractAuditSubscriber.php` — Auto-increment version + write to AuditLog - `src/EventSubscriber/ComposantAuditSubscriber.php` — Enrich snapshot with slots + custom fields + version - `src/EventSubscriber/PieceAuditSubscriber.php` — Enrich snapshot with productSlots + custom fields + version - `src/EventSubscriber/ProductAuditSubscriber.php` — Enrich snapshot with custom fields + version - `src/EventSubscriber/MachineAuditSubscriber.php` — Enrich snapshot with custom fields + version - `src/Repository/AuditLogRepository.php` — Add `findVersionHistory()` method ### Frontend — New Files - `frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore - `frontend/app/components/common/EntityVersionList.vue` — Version list with restore button - `frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal ### Frontend — Modified Files - `frontend/app/pages/machine/[id].vue` — Add Versions section - `frontend/app/pages/component/[id]/edit.vue` — Add Versions section - `frontend/app/pages/piece/[id].vue` — Add Versions section - `frontend/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 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** ```bash 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: ```php #[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: ```php 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()** ```php 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** ```bash 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)** ```php #[ORM\Column(type: Types::INTEGER, options: ['default' => 1])] private int $version = 1; ``` - [ ] **Step 2: Add getter and incrementVersion methods after getCustomFieldValues()** ```php 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** ```bash 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): ```php /** * 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): ```php 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): ```php 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** ```bash 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`: ```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`: ```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`: ```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`: ```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** ```bash 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()`: ```php /** * @return list */ 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** ```bash 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 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, 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, 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} */ 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** ```bash 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): ```php /** * 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: ```php 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** ```bash 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 , 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** ```bash 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 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** ```bash 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 `restore` case to `historyActionLabel`** In `historyDisplayUtils.ts`, update `historyActionLabel`: ```typescript 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** ```bash 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** ```typescript 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 | null } export interface RestorePreview { version: number restoreMode: 'full' | 'partial' diff: Record warnings: Array<{ field: string message: string missingEntityId: string | null missingEntityName: string | null }> snapshot: Record } export interface RestoreResult { success: boolean newVersion: number restoredFromVersion: number restoreMode: 'full' | 'partial' warnings: RestorePreview['warnings'] } const ENTITY_ENDPOINTS: Record = { machine: '/machines', composant: '/composants', piece: '/pieces', product: '/products', } interface Deps { entityType: MaybeRef entityId: MaybeRef } export function useEntityVersions(deps: Deps) { const { get, post } = useApi() const versions = ref([]) const loading = ref(false) const error = ref(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 => { const result = await get(`${getPath()}/versions/${version}/preview`) if (!result.success || !result.data) { return null } return result.data } const restore = async (version: number): Promise => { const result = await post(`${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** ```bash 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** ```vue ``` - [ ] **Step 2: Run lint** Run (in `frontend/`): `npm run lint:fix` - [ ] **Step 3: Commit** ```bash 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** ```vue ``` - [ ] **Step 2: Run lint** Run (in `frontend/`): `npm run lint:fix` - [ ] **Step 3: Commit** ```bash 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: ```vue ``` Add the import at the top of `