feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel
This commit is contained in:
26
src/ApiResource/SwitchWorkflowOutput.php
Normal file
26
src/ApiResource/SwitchWorkflowOutput.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final class SwitchWorkflowOutput
|
||||
{
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $projectId;
|
||||
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $workflowId;
|
||||
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $migratedTaskCount;
|
||||
|
||||
public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
|
||||
{
|
||||
$this->projectId = $projectId;
|
||||
$this->workflowId = $workflowId;
|
||||
$this->migratedTaskCount = $migratedTaskCount;
|
||||
}
|
||||
}
|
||||
@@ -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']],
|
||||
|
||||
113
src/State/SwitchProjectWorkflowProcessor.php
Normal file
113
src/State/SwitchProjectWorkflowProcessor.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\SwitchWorkflowOutput;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\Workflow;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Wraps the switch-workflow operation for a project.
|
||||
* Input: Project (URI variable) + body { workflowId, mapping: { sourceStatusId: targetStatusId|null } }.
|
||||
*
|
||||
* @implements ProcessorInterface<Project, SwitchWorkflowOutput>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user