Merge branch 'develop' into feat/mail-integration
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;
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\User;
|
||||
use App\Entity\Workflow;
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Enum\StatusCategory;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
@@ -87,57 +89,31 @@ class AppFixtures extends Fixture
|
||||
$clientNova->setPostalCode('69007');
|
||||
$manager->persist($clientNova);
|
||||
|
||||
// Projets
|
||||
$projectSirh = new Project();
|
||||
$projectSirh->setCode('SIRH');
|
||||
$projectSirh->setName('SIRH');
|
||||
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
|
||||
$projectSirh->setColor('#222783');
|
||||
$projectSirh->setClient($clientLiot);
|
||||
$manager->persist($projectSirh);
|
||||
// Workflow par défaut
|
||||
$standardWorkflow = new Workflow();
|
||||
$standardWorkflow->setName('Standard');
|
||||
$standardWorkflow->setIsDefault(true);
|
||||
$standardWorkflow->setPosition(0);
|
||||
$manager->persist($standardWorkflow);
|
||||
|
||||
$projectCrm = new Project();
|
||||
$projectCrm->setCode('CRM');
|
||||
$projectCrm->setName('CRM');
|
||||
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
|
||||
$projectCrm->setColor('#E91E63');
|
||||
$projectCrm->setClient($clientAcme);
|
||||
$manager->persist($projectCrm);
|
||||
|
||||
$projectErp = new Project();
|
||||
$projectErp->setCode('ERP');
|
||||
$projectErp->setName('ERP');
|
||||
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||
$projectErp->setColor('#4A90D9');
|
||||
$projectErp->setClient($clientNova);
|
||||
$manager->persist($projectErp);
|
||||
|
||||
$projectInterne = new Project();
|
||||
$projectInterne->setCode('SITE');
|
||||
$projectInterne->setName('Site vitrine');
|
||||
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||
$projectInterne->setColor('#26A69A');
|
||||
$projectInterne->setClient(null);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Task Statuses (global)
|
||||
// Task Statuses (rattachés au workflow Standard)
|
||||
$defaultStatuses = [
|
||||
['A faire', '#222783', 0],
|
||||
['En cours', '#4A90D9', 1],
|
||||
['Bloqué', '#C62828', 2],
|
||||
['En attente de validation', '#FF8F00', 3],
|
||||
['Terminé', '#26A69A', 4],
|
||||
['A faire', '#222783', 0, StatusCategory::Todo, false],
|
||||
['En cours', '#4A90D9', 1, StatusCategory::InProgress, false],
|
||||
['Bloqué', '#C62828', 2, StatusCategory::Blocked, false],
|
||||
['En attente de validation', '#FF8F00', 3, StatusCategory::Review, false],
|
||||
['Terminé', '#26A69A', 4, StatusCategory::Done, true],
|
||||
];
|
||||
|
||||
$statusObjects = [];
|
||||
foreach ($defaultStatuses as [$label, $color, $position]) {
|
||||
foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) {
|
||||
$status = new TaskStatus();
|
||||
$status->setLabel($label);
|
||||
$status->setColor($color);
|
||||
$status->setPosition($position);
|
||||
if ('Terminé' === $label) {
|
||||
$status->setIsFinal(true);
|
||||
}
|
||||
$status->setCategory($category);
|
||||
$status->setIsFinal($isFinal);
|
||||
$standardWorkflow->addStatus($status);
|
||||
$manager->persist($status);
|
||||
$statusObjects[$label] = $status;
|
||||
}
|
||||
@@ -148,6 +124,43 @@ class AppFixtures extends Fixture
|
||||
$statusReview = $statusObjects['En attente de validation'];
|
||||
$statusDone = $statusObjects['Terminé'];
|
||||
|
||||
// Projets
|
||||
$projectSirh = new Project();
|
||||
$projectSirh->setCode('SIRH');
|
||||
$projectSirh->setName('SIRH');
|
||||
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
|
||||
$projectSirh->setColor('#222783');
|
||||
$projectSirh->setClient($clientLiot);
|
||||
$projectSirh->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectSirh);
|
||||
|
||||
$projectCrm = new Project();
|
||||
$projectCrm->setCode('CRM');
|
||||
$projectCrm->setName('CRM');
|
||||
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
|
||||
$projectCrm->setColor('#E91E63');
|
||||
$projectCrm->setClient($clientAcme);
|
||||
$projectCrm->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectCrm);
|
||||
|
||||
$projectErp = new Project();
|
||||
$projectErp->setCode('ERP');
|
||||
$projectErp->setName('ERP');
|
||||
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||
$projectErp->setColor('#4A90D9');
|
||||
$projectErp->setClient($clientNova);
|
||||
$projectErp->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectErp);
|
||||
|
||||
$projectInterne = new Project();
|
||||
$projectInterne->setCode('SITE');
|
||||
$projectInterne->setName('Site vitrine');
|
||||
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||
$projectInterne->setColor('#26A69A');
|
||||
$projectInterne->setClient(null);
|
||||
$projectInterne->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Task Efforts
|
||||
$effortS = new TaskEffort();
|
||||
$effortS->setLabel('S');
|
||||
|
||||
@@ -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']],
|
||||
@@ -69,6 +85,12 @@ class Project
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
||||
private ?Workflow $workflow = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?string $giteaOwner = null;
|
||||
@@ -228,6 +250,18 @@ class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkflow(): ?Workflow
|
||||
{
|
||||
return $this->workflow;
|
||||
}
|
||||
|
||||
public function setWorkflow(Workflow $workflow): static
|
||||
{
|
||||
$this->workflow = $workflow;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['project:read'])]
|
||||
public function getTaskCount(): int
|
||||
{
|
||||
|
||||
@@ -478,4 +478,26 @@ class Task
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateStatusBelongsToProjectWorkflow(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null === $this->status || null === $this->project) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectWorkflow = $this->project->getWorkflow();
|
||||
$statusWorkflow = $this->status->getWorkflow();
|
||||
|
||||
if (null === $projectWorkflow || null === $statusWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($projectWorkflow->getId() !== $statusWorkflow->getId()) {
|
||||
$context->buildViolation('Status does not belong to this project\'s workflow.')
|
||||
->atPath('status')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Enum\StatusCategory;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -32,25 +34,36 @@ class TaskStatus
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task:read'])]
|
||||
#[Groups(['task_status:read', 'task:read', 'workflow:read', 'project:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
|
||||
private ?string $color = '#222783';
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
|
||||
private ?int $position = 0;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
|
||||
private bool $isFinal = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Workflow::class, inversedBy: 'statuses')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
#[Assert\NotNull]
|
||||
private ?Workflow $workflow = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32, enumType: StatusCategory::class)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
|
||||
#[Assert\NotNull]
|
||||
private ?StatusCategory $category = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -103,4 +116,28 @@ class TaskStatus
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkflow(): ?Workflow
|
||||
{
|
||||
return $this->workflow;
|
||||
}
|
||||
|
||||
public function setWorkflow(?Workflow $workflow): static
|
||||
{
|
||||
$this->workflow = $workflow;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategory(): ?StatusCategory
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
public function setCategory(StatusCategory $category): static
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
131
src/Entity/Workflow.php
Normal file
131
src/Entity/Workflow.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\WorkflowRepository;
|
||||
use App\State\WorkflowDeleteProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
|
||||
],
|
||||
normalizationContext: ['groups' => ['workflow:read']],
|
||||
denormalizationContext: ['groups' => ['workflow:write']],
|
||||
order: ['position' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: WorkflowRepository::class)]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')]
|
||||
class Workflow
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['workflow:read', 'project:read', 'task_status:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, unique: true)]
|
||||
#[Groups(['workflow:read', 'workflow:write', 'project:read'])]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['workflow:read', 'workflow:write'])]
|
||||
private bool $isDefault = false;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||
#[Groups(['workflow:read', 'workflow:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
/** @var Collection<int, TaskStatus> */
|
||||
#[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
#[Groups(['workflow:read', 'project:read'])]
|
||||
private Collection $statuses;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->statuses = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->isDefault;
|
||||
}
|
||||
|
||||
public function setIsDefault(bool $isDefault): static
|
||||
{
|
||||
$this->isDefault = $isDefault;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TaskStatus> */
|
||||
public function getStatuses(): Collection
|
||||
{
|
||||
return $this->statuses;
|
||||
}
|
||||
|
||||
public function addStatus(TaskStatus $status): static
|
||||
{
|
||||
if (!$this->statuses->contains($status)) {
|
||||
$this->statuses->add($status);
|
||||
$status->setWorkflow($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeStatus(TaskStatus $status): static
|
||||
{
|
||||
$this->statuses->removeElement($status);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
14
src/Enum/StatusCategory.php
Normal file
14
src/Enum/StatusCategory.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum StatusCategory: string
|
||||
{
|
||||
case Todo = 'todo';
|
||||
case InProgress = 'in_progress';
|
||||
case Blocked = 'blocked';
|
||||
case Review = 'review';
|
||||
case Done = 'done';
|
||||
}
|
||||
46
src/EventListener/UniqueDefaultWorkflowListener.php
Normal file
46
src/EventListener/UniqueDefaultWorkflowListener.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Workflow;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class UniqueDefaultWorkflowListener
|
||||
{
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
$uow = $em->getUnitOfWork();
|
||||
|
||||
$candidates = [];
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof Workflow && $entity->isDefault()) {
|
||||
$candidates[] = $entity;
|
||||
}
|
||||
}
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if ($entity instanceof Workflow && $entity->isDefault()) {
|
||||
$candidates[] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === count($candidates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = $em->getClassMetadata(Workflow::class);
|
||||
$repo = $em->getRepository(Workflow::class);
|
||||
foreach ($repo->findBy(['isDefault' => true]) as $existing) {
|
||||
if (in_array($existing, $candidates, true)) {
|
||||
continue;
|
||||
}
|
||||
$existing->setIsDefault(false);
|
||||
$uow->recomputeSingleEntityChangeSet($metadata, $existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
|
||||
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs. The status parameter must reference a status that belongs to the target project\'s workflow — otherwise the call is rejected with a validation error.')]
|
||||
class CreateTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -22,7 +22,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
|
||||
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs. The status parameter must reference a status that belongs to the task\'s project workflow — otherwise the call is rejected with a validation error.')]
|
||||
class UpdateTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -4,33 +4,49 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
|
||||
#[McpTool(
|
||||
name: 'list-statuses',
|
||||
description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).',
|
||||
)]
|
||||
class ListStatusesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
public function __invoke(?int $projectId = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
|
||||
if (null !== $projectId) {
|
||||
$project = $this->entityManager->find(Project::class, $projectId);
|
||||
if (!$project) {
|
||||
return json_encode(['error' => 'Project not found.']);
|
||||
}
|
||||
$statuses = $project->getWorkflow()->getStatuses()->toArray();
|
||||
} else {
|
||||
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
|
||||
}
|
||||
|
||||
return json_encode(array_map(fn ($s) => [
|
||||
'id' => $s->getId(),
|
||||
'label' => $s->getLabel(),
|
||||
'color' => $s->getColor(),
|
||||
'position' => $s->getPosition(),
|
||||
'isFinal' => $s->getIsFinal(),
|
||||
'id' => $s->getId(),
|
||||
'label' => $s->getLabel(),
|
||||
'color' => $s->getColor(),
|
||||
'position' => $s->getPosition(),
|
||||
'isFinal' => $s->getIsFinal(),
|
||||
'category' => $s->getCategory()->value,
|
||||
'workflowId' => $s->getWorkflow()?->getId(),
|
||||
], $statuses));
|
||||
}
|
||||
}
|
||||
|
||||
46
src/Mcp/Tool/Workflow/ListWorkflowsTool.php
Normal file
46
src/Mcp/Tool/Workflow/ListWorkflowsTool.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Workflow;
|
||||
|
||||
use App\Repository\WorkflowRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list-workflows',
|
||||
description: 'List all workflows (status templates) with their statuses grouped under each workflow. Each project has one workflow that defines its kanban columns.',
|
||||
)]
|
||||
class ListWorkflowsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkflowRepository $workflowRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($w) => [
|
||||
'id' => $w->getId(),
|
||||
'name' => $w->getName(),
|
||||
'isDefault' => $w->isDefault(),
|
||||
'position' => $w->getPosition(),
|
||||
'statuses' => array_map(fn ($s) => [
|
||||
'id' => $s->getId(),
|
||||
'label' => $s->getLabel(),
|
||||
'color' => $s->getColor(),
|
||||
'position' => $s->getPosition(),
|
||||
'isFinal' => $s->getIsFinal(),
|
||||
'category' => $s->getCategory()->value,
|
||||
], $w->getStatuses()->toArray()),
|
||||
], $workflows));
|
||||
}
|
||||
}
|
||||
65
src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php
Normal file
65
src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Workflow;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Entity\Project;
|
||||
use App\State\SwitchProjectWorkflowProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Throwable;
|
||||
|
||||
#[McpTool(
|
||||
name: 'switch-project-workflow',
|
||||
description: 'Switch a project to another workflow. mapping must cover every status currently used by the project\'s tasks: keys are source status IDs (string), values are target status IDs in the new workflow (int) or null to send tasks to backlog. Requires ROLE_ADMIN. Returns { migratedTaskCount }.',
|
||||
)]
|
||||
class SwitchProjectWorkflowTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SwitchProjectWorkflowProcessor $processor,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, null|int> $mapping
|
||||
*/
|
||||
public function __invoke(int $projectId, int $workflowId, array $mapping): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$project = $this->entityManager->find(Project::class, $projectId);
|
||||
if (!$project) {
|
||||
return json_encode(['error' => 'Project not found.']);
|
||||
}
|
||||
|
||||
$fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([
|
||||
'workflowId' => $workflowId,
|
||||
'mapping' => $mapping,
|
||||
]));
|
||||
|
||||
try {
|
||||
$result = $this->processor->process(
|
||||
$project,
|
||||
operation: new Post(name: 'switch_workflow'),
|
||||
uriVariables: ['id' => $projectId],
|
||||
context: ['request' => $fakeRequest],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'migratedTaskCount' => $result->migratedTaskCount,
|
||||
'projectId' => $result->projectId,
|
||||
'workflowId' => $result->workflowId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
src/Repository/WorkflowRepository.php
Normal file
25
src/Repository/WorkflowRepository.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Workflow;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Workflow>
|
||||
*/
|
||||
class WorkflowRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Workflow::class);
|
||||
}
|
||||
|
||||
public function findDefault(): ?Workflow
|
||||
{
|
||||
return $this->findOneBy(['isDefault' => true]);
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/State/WorkflowDeleteProcessor.php
Normal file
42
src/State/WorkflowDeleteProcessor.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Workflow;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<Workflow, void>
|
||||
*/
|
||||
final readonly class WorkflowDeleteProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
||||
{
|
||||
/** @var Workflow $workflow */
|
||||
$workflow = $data;
|
||||
|
||||
$count = (int) $this->entityManager->getConnection()->fetchOne(
|
||||
'SELECT COUNT(*) FROM project WHERE workflow_id = :id',
|
||||
['id' => $workflow->getId()],
|
||||
);
|
||||
|
||||
if ($count > 0) {
|
||||
throw new HttpException(409, sprintf(
|
||||
'Workflow used by %d project(s). Reassign them before deleting.',
|
||||
$count,
|
||||
));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($workflow);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user