feat(versioning) : add entity versioning with numbered versions and restore
Backend: - Migration: version column on audit_logs and machines - AuditLog, Machine, Composant, Piece, Product: version + skipAudit properties - AbstractAuditSubscriber: auto-increment version, skip on restore, fix decimal diff - Enriched snapshots with slots, custom fields and version number - AuditLogRepository: findVersionHistory, findByVersion - EntityVersionService: list, preview, restore with skeleton/integrity checks - EntityVersionController: REST endpoints for all 4 entity types - 11 tests covering list, preview, restore, auth Frontend: update submodule pointer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
src/Controller/EntityVersionController.php
Normal file
155
src/Controller/EntityVersionController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?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 InvalidArgumentException;
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user