From 6a084489eaeb5d5a18751422bc2d98f86626fa16 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:59:42 +0200 Subject: [PATCH] feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel --- src/ApiResource/SwitchWorkflowOutput.php | 26 +++++ src/Entity/Project.php | 16 +++ src/State/SwitchProjectWorkflowProcessor.php | 113 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/ApiResource/SwitchWorkflowOutput.php create mode 100644 src/State/SwitchProjectWorkflowProcessor.php diff --git a/src/ApiResource/SwitchWorkflowOutput.php b/src/ApiResource/SwitchWorkflowOutput.php new file mode 100644 index 0000000..ddc3133 --- /dev/null +++ b/src/ApiResource/SwitchWorkflowOutput.php @@ -0,0 +1,26 @@ +projectId = $projectId; + $this->workflowId = $workflowId; + $this->migratedTaskCount = $migratedTaskCount; + } +} diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 6191f01..04ce728 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -10,9 +10,12 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use App\ApiResource\SwitchWorkflowOutput; use App\Repository\ProjectRepository; +use App\State\SwitchProjectWorkflowProcessor; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -30,6 +33,19 @@ use Symfony\Component\Validator\Constraints as Assert; ), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), + new Post( + uriTemplate: '/projects/{id}/switch-workflow', + uriVariables: ['id' => new Link(fromClass: Project::class)], + security: "is_granted('ROLE_ADMIN')", + input: false, + output: SwitchWorkflowOutput::class, + normalizationContext: ['groups' => ['switch_workflow:read']], + processor: SwitchProjectWorkflowProcessor::class, + read: true, + deserialize: false, + validate: false, + name: 'switch_workflow', + ), ], normalizationContext: ['groups' => ['project:read']], denormalizationContext: ['groups' => ['project:write']], diff --git a/src/State/SwitchProjectWorkflowProcessor.php b/src/State/SwitchProjectWorkflowProcessor.php new file mode 100644 index 0000000..c5822ae --- /dev/null +++ b/src/State/SwitchProjectWorkflowProcessor.php @@ -0,0 +1,113 @@ + + */ +final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput + { + /** @var Project $project */ + $project = $data; + + $request = $context['request'] ?? null; + $body = $request ? json_decode($request->getContent(), true) : []; + + $workflowId = $body['workflowId'] ?? null; + $mapping = $body['mapping'] ?? []; + + if (!is_int($workflowId) || !is_array($mapping)) { + throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).'); + } + + $targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId); + if (!$targetWorkflow instanceof Workflow) { + throw new NotFoundHttpException('Target workflow not found.'); + } + + // 1) Lister les statuts source effectivement référencés par les tâches du projet + $rows = $this->entityManager->getConnection()->fetchAllAssociative( + 'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL', + ['pid' => $project->getId()], + ); + $referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows); + + // 2) Vérifier que chaque source a un mapping + $missing = []; + foreach ($referencedSourceIds as $srcId) { + if (!array_key_exists((string) $srcId, $mapping)) { + $missing[] = $srcId; + } + } + if ([] !== $missing) { + throw new HttpException(422, 'Missing mapping for source status IDs: '.implode(', ', $missing)); + } + + // 3) Valider que chaque target appartient au workflow cible (ou est null) + foreach ($mapping as $srcId => $targetId) { + if (null === $targetId) { + continue; + } + $target = $this->entityManager->find(TaskStatus::class, $targetId); + if (!$target instanceof TaskStatus + || $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) { + throw new HttpException(422, sprintf( + 'Target status %s does not belong to workflow %d.', + var_export($targetId, true), + $targetWorkflow->getId(), + )); + } + } + + // 4) Transaction unique + $conn = $this->entityManager->getConnection(); + $conn->beginTransaction(); + + try { + $migrated = 0; + foreach ($mapping as $srcId => $targetId) { + $affected = $conn->executeStatement( + 'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid', + ['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId], + ); + $migrated += $affected; + } + + $project->setWorkflow($targetWorkflow); + $this->entityManager->flush(); + $conn->commit(); + } catch (Throwable $e) { + $conn->rollBack(); + + throw $e; + } + + return new SwitchWorkflowOutput( + projectId: $project->getId(), + workflowId: $targetWorkflow->getId(), + migratedTaskCount: $migrated, + ); + } +}