feat(project-management) : migrate core Projects/Tasks domain into module (back)

Tranche 2 of LST-65. Mechanical, behaviour-preserving move of the core
business domain into src/Module/ProjectManagement/. API operations,
securities, uriTemplates and the 38 MCP tool names are all unchanged.

- 10 entities + 2 enums moved to Domain/{Entity,Enum}; intra-module
  relations stay concrete, cross-module relations go through contracts
  (Project.client -> ClientInterface, Task/TaskDocument users ->
  UserInterface).
- 9 repositories split into Domain/Repository interfaces + Doctrine impls,
  bound in services.yaml; consumers inject the interfaces. find() kept off
  the interfaces (ServiceEntityRepository ?object compat) -> findById().
- State (7), MCP tools (38), controller, CalDavService/RecurrenceCalculator,
  3 Doctrine listeners and SwitchWorkflowOutput moved under Infrastructure/.
- doctrine.yaml: ProjectManagement mapping + resolve_target_entities of the
  3 module contracts repointed to the module (ClientInterface stays legacy).
- ProjectManagementModule registered (id project-management, 4 RBAC perms,
  not re-wired); sidebar my-tasks/projects gated by the module.
- Legacy not-yet-modularised consumers (Mail/Gitea/BookStack, Serializer,
  fixtures, tests) swapped to the module FQCN — transitional coupling to be
  cleaned in 2.4/2.5/2.6.

159 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:54:59 +02:00
parent f119ec30ca
commit 23809f165e
119 changed files with 779 additions and 454 deletions
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
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\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource\SwitchWorkflowOutput;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\SwitchProjectWorkflowProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\ProjectInterface;
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(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],
),
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']],
order: ['name' => 'ASC'],
)]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: DoctrineProjectRepository::class)]
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
class Project implements ProjectInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 10, unique: true)]
#[Groups(['project:read', 'project:create', 'task:read'])]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[A-Z]{2,10}$/', message: 'Le code doit contenir entre 2 et 10 lettres majuscules.')]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read', 'user:list'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['project:read', 'project:write'])]
private ?string $description = null;
#[ORM\Column(length: 7)]
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
private ?string $color = '#222783';
#[ORM\ManyToOne(targetEntity: ClientInterface::class, inversedBy: 'projects')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['project:read', 'project:write'])]
private ?ClientInterface $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;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['project:read', 'project:write', 'task:read'])]
private ?string $giteaRepo = null;
#[ORM\Column(nullable: true)]
#[Groups(['project:read', 'project:write', 'task:read'])]
private ?int $bookstackShelfId = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['project:read', 'project:write'])]
private ?string $bookstackShelfName = null;
#[ORM\Column]
#[Groups(['project:read', 'project:write'])]
private bool $archived = false;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
public function getGiteaOwner(): ?string
{
return $this->giteaOwner;
}
public function setGiteaOwner(?string $giteaOwner): static
{
$this->giteaOwner = $giteaOwner;
return $this;
}
public function getGiteaRepo(): ?string
{
return $this->giteaRepo;
}
public function setGiteaRepo(?string $giteaRepo): static
{
$this->giteaRepo = $giteaRepo;
return $this;
}
public function hasGiteaRepo(): bool
{
return null !== $this->giteaOwner && null !== $this->giteaRepo;
}
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
public function getBookstackShelfId(): ?int
{
return $this->bookstackShelfId;
}
public function setBookstackShelfId(?int $bookstackShelfId): static
{
$this->bookstackShelfId = $bookstackShelfId;
return $this;
}
public function getBookstackShelfName(): ?string
{
return $this->bookstackShelfName;
}
public function setBookstackShelfName(?string $bookstackShelfName): static
{
$this->bookstackShelfName = $bookstackShelfName;
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
{
return $this->tasks->count();
}
}
@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
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\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
],
normalizationContext: ['groups' => ['task:read']],
denormalizationContext: ['groups' => ['task:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'collaborators' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
#[ORM\Entity(repositoryClass: DoctrineTaskRepository::class)]
#[ORM\Table(name: 'task')]
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
class Task implements TaskInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
#[Groups(['task:read'])]
private ?int $number = null;
#[ORM\Column(length: 255)]
#[Groups(['task:read', 'task:write'])]
private ?string $title = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?string $description = null;
#[ORM\ManyToOne(targetEntity: TaskStatus::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskStatus $status = null;
#[ORM\ManyToOne(targetEntity: TaskEffort::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskEffort $effort = null;
#[ORM\ManyToOne(targetEntity: TaskPriority::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskPriority $priority = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?UserInterface $assignee = null;
/** @var Collection<int, UserInterface> */
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
#[ORM\JoinTable(
name: 'task_collaborator',
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
)]
#[Groups(['task:read', 'task:write'])]
private Collection $collaborators;
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskGroup $group = null;
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task:read', 'task:write'])]
private ?Project $project = null;
/** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
#[ORM\JoinTable(
name: 'task_task_type',
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'task_type_id', referencedColumnName: 'id')],
)]
#[Groups(['task:read', 'task:write'])]
private Collection $tags;
#[ORM\Column(type: 'boolean')]
#[Groups(['task:read', 'task:write'])]
private bool $archived = false;
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
#[Groups(['task:read'])]
private Collection $documents;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $scheduledStart = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $scheduledEnd = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['task:read', 'task:write'])]
private ?DateTimeImmutable $deadline = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['task:read', 'task:write'])]
private bool $syncToCalendar = false;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarEventUid = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $calendarTodoUid = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['task:read'])]
private ?string $calendarSyncError = null;
#[ORM\ManyToOne(targetEntity: TaskRecurrence::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?TaskRecurrence $recurrence = null;
public function __construct()
{
$this->tags = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->collaborators = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getStatus(): ?TaskStatus
{
return $this->status;
}
public function setStatus(?TaskStatus $status): static
{
$this->status = $status;
return $this;
}
public function getEffort(): ?TaskEffort
{
return $this->effort;
}
public function setEffort(?TaskEffort $effort): static
{
$this->effort = $effort;
return $this;
}
public function getPriority(): ?TaskPriority
{
return $this->priority;
}
public function setPriority(?TaskPriority $priority): static
{
$this->priority = $priority;
return $this;
}
public function getAssignee(): ?UserInterface
{
return $this->assignee;
}
public function setAssignee(?UserInterface $assignee): static
{
$this->assignee = $assignee;
return $this;
}
/** @return Collection<int, UserInterface> */
public function getCollaborators(): Collection
{
return $this->collaborators;
}
public function addCollaborator(UserInterface $user): static
{
if (!$this->collaborators->contains($user)) {
$this->collaborators->add($user);
}
return $this;
}
public function removeCollaborator(UserInterface $user): static
{
$this->collaborators->removeElement($user);
return $this;
}
public function getGroup(): ?TaskGroup
{
return $this->group;
}
public function setGroup(?TaskGroup $group): static
{
$this->group = $group;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
/** @return Collection<int, TaskTag> */
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(TaskTag $tag): static
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
}
return $this;
}
public function removeTag(TaskTag $tag): static
{
$this->tags->removeElement($tag);
return $this;
}
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
public function getScheduledStart(): ?DateTimeImmutable
{
return $this->scheduledStart;
}
public function setScheduledStart(?DateTimeImmutable $scheduledStart): static
{
$this->scheduledStart = $scheduledStart;
return $this;
}
public function getScheduledEnd(): ?DateTimeImmutable
{
return $this->scheduledEnd;
}
public function setScheduledEnd(?DateTimeImmutable $scheduledEnd): static
{
$this->scheduledEnd = $scheduledEnd;
return $this;
}
public function getDeadline(): ?DateTimeImmutable
{
return $this->deadline;
}
public function setDeadline(?DateTimeImmutable $deadline): static
{
$this->deadline = $deadline;
return $this;
}
public function isSyncToCalendar(): bool
{
return $this->syncToCalendar;
}
public function setSyncToCalendar(bool $syncToCalendar): static
{
$this->syncToCalendar = $syncToCalendar;
return $this;
}
public function getCalendarEventUid(): ?string
{
return $this->calendarEventUid;
}
public function setCalendarEventUid(?string $calendarEventUid): static
{
$this->calendarEventUid = $calendarEventUid;
return $this;
}
public function getCalendarTodoUid(): ?string
{
return $this->calendarTodoUid;
}
public function setCalendarTodoUid(?string $calendarTodoUid): static
{
$this->calendarTodoUid = $calendarTodoUid;
return $this;
}
public function getCalendarSyncError(): ?string
{
return $this->calendarSyncError;
}
public function setCalendarSyncError(?string $calendarSyncError): static
{
$this->calendarSyncError = $calendarSyncError;
return $this;
}
public function getRecurrence(): ?TaskRecurrence
{
return $this->recurrence;
}
public function setRecurrence(?TaskRecurrence $recurrence): static
{
$this->recurrence = $recurrence;
return $this;
}
#[Assert\Callback]
public function validateScheduledDates(ExecutionContextInterface $context): void
{
if ((null === $this->scheduledStart) !== (null === $this->scheduledEnd)) {
$context->buildViolation('scheduledStart and scheduledEnd must both be set or both be null.')
->atPath('scheduledEnd')
->addViolation()
;
}
if (null !== $this->scheduledStart && null !== $this->scheduledEnd
&& $this->scheduledEnd <= $this->scheduledStart) {
$context->buildViolation('scheduledEnd must be after scheduledStart.')
->atPath('scheduledEnd')
->addViolation()
;
}
}
#[Assert\Callback]
public function validateCollaborators(ExecutionContextInterface $context): void
{
if (null !== $this->assignee && $this->collaborators->contains($this->assignee)) {
$context->buildViolation('The assignee cannot also be a collaborator.')
->atPath('collaborators')
->addViolation()
;
}
}
#[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()
;
}
}
}
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProvider;
use App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([TaskDocumentListener::class])]
class TaskDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $fileName = null;
/**
* Chemin relatif sur le partage SMB lorsque le document est un lien vers un fichier du partage
* (au lieu d'un fichier uploadé stocké sur disque). Mutuellement exclusif avec fileName.
*/
#[ORM\Column(length: 1024, nullable: true)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $sharePath = null;
#[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['task_document:read', 'task:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read'])]
private ?UserInterface $uploadedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): static
{
$this->task = $task;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
}
public function setOriginalName(string $originalName): static
{
$this->originalName = $originalName;
return $this;
}
public function getFileName(): ?string
{
return $this->fileName;
}
public function setFileName(?string $fileName): static
{
$this->fileName = $fileName;
return $this;
}
public function getSharePath(): ?string
{
return $this->sharePath;
}
public function setSharePath(?string $sharePath): static
{
$this->sharePath = $sharePath;
return $this;
}
public function isShareLink(): bool
{
return null !== $this->sharePath;
}
public function getMimeType(): ?string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): ?int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUploadedBy(): ?UserInterface
{
return $this->uploadedBy;
}
public function setUploadedBy(?UserInterface $uploadedBy): static
{
$this->uploadedBy = $uploadedBy;
return $this;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_effort:read']],
denormalizationContext: ['groups' => ['task_effort:write']],
order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrineTaskEffortRepository::class)]
class TaskEffort
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_effort:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(length: 50)]
#[Groups(['task_effort:read', 'task_effort:write', 'task:read'])]
private ?string $label = null;
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
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\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_group:read']],
denormalizationContext: ['groups' => ['task_group:write']],
order: ['title' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: DoctrineTaskGroupRepository::class)]
class TaskGroup
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_group:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
private ?string $title = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['task_group:read', 'task_group:write'])]
private ?string $description = null;
#[ORM\Column(length: 7)]
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
private ?string $color = '#222783';
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_group:read', 'task_group:write'])]
private ?Project $project = null;
#[ORM\Column(type: 'boolean')]
#[Groups(['task_group:read', 'task_group:write', 'task:read'])]
private bool $archived = false;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_priority:read']],
denormalizationContext: ['groups' => ['task_priority:write']],
order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrineTaskPriorityRepository::class)]
class TaskPriority
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_priority:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
private ?string $color = '#222783';
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_recurrence:read']],
denormalizationContext: ['groups' => ['task_recurrence:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineTaskRecurrenceRepository::class)]
class TaskRecurrence
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_recurrence:read', 'task:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', enumType: RecurrenceType::class)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?RecurrenceType $type = null;
#[ORM\Column(type: 'integer')]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private int $interval = 1;
#[ORM\Column(type: 'json', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?array $daysOfWeek = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $dayOfMonth = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $weekOfMonth = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['task_recurrence:read', 'task_recurrence:write', 'task:read'])]
private ?int $maxOccurrences = null;
#[ORM\Column(type: 'integer')]
#[Groups(['task_recurrence:read'])]
private int $occurrenceCount = 0;
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?RecurrenceType
{
return $this->type;
}
public function setType(RecurrenceType $type): static
{
$this->type = $type;
return $this;
}
public function getInterval(): int
{
return $this->interval;
}
public function setInterval(int $interval): static
{
$this->interval = $interval;
return $this;
}
public function getDaysOfWeek(): ?array
{
return $this->daysOfWeek;
}
public function setDaysOfWeek(?array $daysOfWeek): static
{
$this->daysOfWeek = $daysOfWeek;
return $this;
}
public function getDayOfMonth(): ?int
{
return $this->dayOfMonth;
}
public function setDayOfMonth(?int $dayOfMonth): static
{
$this->dayOfMonth = $dayOfMonth;
return $this;
}
public function getWeekOfMonth(): ?int
{
return $this->weekOfMonth;
}
public function setWeekOfMonth(?int $weekOfMonth): static
{
$this->weekOfMonth = $weekOfMonth;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getMaxOccurrences(): ?int
{
return $this->maxOccurrences;
}
public function setMaxOccurrences(?int $maxOccurrences): static
{
$this->maxOccurrences = $maxOccurrences;
return $this;
}
public function getOccurrenceCount(): int
{
return $this->occurrenceCount;
}
public function getVersion(): int
{
return $this->version;
}
/** @return Collection<int, Task> */
public function getTasks(): Collection
{
return $this->tasks;
}
public function incrementOccurrenceCount(): static
{
++$this->occurrenceCount;
return $this;
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Domain\Enum\StatusCategory;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_status:read']],
denormalizationContext: ['groups' => ['task_status:write']],
order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrineTaskStatusRepository::class)]
class TaskStatus
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[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', 'workflow:read', 'project:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[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', 'workflow:read', 'project:read'])]
private ?int $position = 0;
#[ORM\Column(type: 'boolean')]
#[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;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
public function getPosition(): ?int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
public function getIsFinal(): bool
{
return $this->isFinal;
}
public function setIsFinal(bool $isFinal): static
{
$this->isFinal = $isFinal;
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;
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository;
use App\Shared\Domain\Contract\TaskTagInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, 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')"),
],
normalizationContext: ['groups' => ['task_tag:read']],
denormalizationContext: ['groups' => ['task_tag:write']],
order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrineTaskTagRepository::class)]
#[ORM\Table(name: 'task_type')]
class TaskTag implements TaskTagInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_tag:read', 'task:read', 'time_entry:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
private ?string $color = '#222783';
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\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\Module\ProjectManagement\Infrastructure\ApiPlatform\State\WorkflowDeleteProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository;
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(paginationEnabled: false, 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: DoctrineWorkflowRepository::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;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Enum;
enum RecurrenceType: string
{
case Daily = 'daily';
case Weekly = 'weekly';
case Monthly = 'monthly';
case Yearly = 'yearly';
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Enum;
enum StatusCategory: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Blocked = 'blocked';
case Review = 'review';
case Done = 'done';
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\Project;
interface ProjectRepositoryInterface
{
public function findById(int $id): ?Project;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Project[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
interface TaskEffortRepositoryInterface
{
public function findById(int $id): ?TaskEffort;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return TaskEffort[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
interface TaskGroupRepositoryInterface
{
public function findById(int $id): ?TaskGroup;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return TaskGroup[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
interface TaskPriorityRepositoryInterface
{
public function findById(int $id): ?TaskPriority;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return TaskPriority[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
interface TaskRecurrenceRepositoryInterface
{
public function findById(int $id): ?TaskRecurrence;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use Doctrine\ORM\QueryBuilder;
interface TaskRepositoryInterface
{
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder;
public function findById(int $id): ?Task;
/**
* Returns the max task number for a project, using an advisory lock
* to prevent race conditions when creating tasks concurrently.
*/
public function findMaxNumberByProjectForUpdate(Project $project): int;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
interface TaskStatusRepositoryInterface
{
public function findById(int $id): ?TaskStatus;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return TaskStatus[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findFirstNonFinal(): ?TaskStatus;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
interface TaskTagRepositoryInterface
{
public function findById(int $id): ?TaskTag;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return TaskTag[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Domain\Repository;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
interface WorkflowRepositoryInterface
{
public function findById(int $id): ?Workflow;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Workflow[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findDefault(): ?Workflow;
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource;
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;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use App\Module\ProjectManagement\Infrastructure\Service\RecurrenceCalculator;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RecurrenceHandler
{
public function __construct(
private RecurrenceCalculator $calculator,
private TaskRepositoryInterface $taskRepository,
private TaskStatusRepositoryInterface $statusRepository,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
) {}
public function handleIfNeeded(Task $task, bool $wasAlreadyFinal): void
{
// Only trigger on STATUS CHANGE to isFinal
$currentStatus = $task->getStatus();
$isNowFinal = $currentStatus?->getIsFinal() ?? false;
if (!$isNowFinal || $wasAlreadyFinal) {
return; // No transition to final
}
$recurrence = $task->getRecurrence();
if (null === $recurrence) {
return; // Not a recurring task
}
if ($this->calculator->hasReachedEnd($recurrence)) {
return; // Recurrence is done
}
$nextStart = $this->calculator->getNextDate($task);
if (null === $nextStart) {
return;
}
// Archive current task, clear calendar UIDs
$savedEventUid = $task->getCalendarEventUid();
$task->setArchived(true);
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
// Create new task with same fields
$newTask = new Task();
$newTask->setProject($task->getProject());
$newTask->setTitle($task->getTitle());
$newTask->setDescription($task->getDescription());
$newTask->setAssignee($task->getAssignee());
$newTask->setEffort($task->getEffort());
$newTask->setPriority($task->getPriority());
$newTask->setGroup($task->getGroup());
$newTask->setRecurrence($recurrence);
$newTask->setSyncToCalendar($task->isSyncToCalendar());
// Copy tags
foreach ($task->getTags() as $tag) {
$newTask->addTag($tag);
}
// Set first non-final status
$firstStatus = $this->statusRepository->findFirstNonFinal();
$newTask->setStatus($firstStatus);
// Set recalculated dates
$newTask->setScheduledStart($nextStart);
$newTask->setScheduledEnd($this->calculator->getNextEnd($task, $nextStart));
$newTask->setDeadline($this->calculator->getNextDeadline($task, $nextStart));
// Copy calendar event UID (recurring VEVENT is shared)
$newTask->setCalendarEventUid($savedEventUid);
// Generate task number in transaction
$this->entityManager->wrapInTransaction(function () use ($newTask): void {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($newTask->getProject());
$newTask->setNumber($maxNumber + 1);
$this->entityManager->persist($newTask);
$this->entityManager->flush();
});
// Increment occurrence count (with optimistic locking via @Version)
$recurrence->incrementOccurrenceCount();
$this->entityManager->flush();
// Sync new task's VTODO (new deadline) to Zimbra
if ($newTask->isSyncToCalendar() && $newTask->getDeadline()) {
$uid = $this->calDavService->createTodo($newTask);
if ($uid) {
$newTask->setCalendarTodoUid($uid);
$newTask->setCalendarSyncError(null);
$this->entityManager->flush();
}
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource\SwitchWorkflowOutput;
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,
);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskCalendarProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
* @param ProcessorInterface<Task, Task> $removeProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private CalDavService $calDavService,
private EntityManagerInterface $entityManager,
private RecurrenceHandler $recurrenceHandler,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Delete) {
$eventUid = $data->getCalendarEventUid();
$todoUid = $data->getCalendarTodoUid();
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
if ($eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if ($todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return $result;
}
// Detect isFinal transition using Doctrine UnitOfWork.
// $data already has the NEW values (API Platform deserialized the PATCH).
// UnitOfWork originalEntityData stores the DB snapshot with entity references for relations.
$uow = $this->entityManager->getUnitOfWork();
$originalData = $uow->getOriginalEntityData($data);
$wasAlreadyFinal = false;
if (isset($originalData['status']) && $originalData['status'] instanceof TaskStatus) {
$wasAlreadyFinal = $originalData['status']->getIsFinal();
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Sync to Zimbra after DB flush
$this->calDavService->syncTask($data);
$this->entityManager->flush();
// Check for recurrence auto-creation (only on STATUS CHANGE to isFinal)
$this->recurrenceHandler->handleIfNeeded($data, $wasAlreadyFinal);
return $result;
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
use function in_array;
/**
* @implements ProcessorInterface<TaskDocument, TaskDocument>
*/
final readonly class TaskDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed', 'application/gzip',
'application/json', 'application/xml', 'text/xml',
];
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz',
'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml',
];
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private FileSource $fileSource,
private SharePathResolver $pathResolver,
private string $uploadDir,
) {}
/**
* @param TaskDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
// configuration de sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
// Deux modes de création : upload d'un fichier (multipart) ou lien vers un fichier du partage SMB (JSON).
$sharePath = $this->extractSharePath($request);
$document = null !== $sharePath
? $this->createShareLink($request, $sharePath)
: $this->createUpload($request);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document;
}
private function extractSharePath(Request $request): ?string
{
// Lien SMB : champ multipart/form OU corps JSON { "sharePath": "..." }
$fromForm = $request->request->get('sharePath');
if (is_string($fromForm) && '' !== $fromForm) {
return $fromForm;
}
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
$payload = json_decode($request->getContent() ?: '{}', true);
if (is_array($payload) && isset($payload['sharePath']) && is_string($payload['sharePath']) && '' !== $payload['sharePath']) {
return $payload['sharePath'];
}
}
return null;
}
private function createUpload(Request $request): TaskDocument
{
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$task = $this->resolveTask($request->request->get('task', ''));
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$fileSize = $file->getSize();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
}
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
return $document;
}
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
{
$taskIri = $request->request->get('task');
if (!is_string($taskIri) || '' === $taskIri) {
$payload = json_decode($request->getContent() ?: '{}', true);
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
}
$task = $this->resolveTask((string) $taskIri);
try {
$path = $this->pathResolver->normalizeRelative($rawSharePath);
} catch (InvalidPathException) {
throw new BadRequestHttpException('Invalid share path.');
}
if ('' === $path) {
throw new BadRequestHttpException('A share path is required.');
}
$entry = $this->findShareEntry($path);
if (null === $entry) {
throw new BadRequestHttpException('File not found on the share.');
}
if (!in_array($entry->mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $entry->mimeType));
}
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($entry->name);
$document->setSharePath($path);
$document->setMimeType($entry->mimeType);
$document->setSize($entry->size);
return $document;
}
/**
* Récupère les métadonnées (taille, type) du fichier sur le partage en listant son dossier parent.
*/
private function findShareEntry(string $path): ?FileEntry
{
$parent = dirname($path);
$parent = ('.' === $parent || '/' === $parent) ? '' : $parent;
$name = basename($path);
try {
$entries = $this->fileSource->dir($parent);
} catch (ShareNotConfiguredException) {
throw new BadRequestHttpException('Share not configured.');
} catch (ShareConnectionException) {
throw new BadRequestHttpException('Unable to reach the share.');
}
foreach ($entries as $entry) {
if (!$entry->isDir && $entry->name === $name) {
return $entry;
}
}
return null;
}
private function resolveTask(string $taskIri): Task
{
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
}
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
return $task;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|TaskDocument|null
{
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item
if (isset($uriVariables['id'])) {
return $repo->find($uriVariables['id']);
}
// Collection
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
// Apply filters from query parameters
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
return $qb->getQuery()->getResult();
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Task, Task>
*/
final readonly class TaskNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<Task, Task> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private TaskRepositoryInterface $taskRepository,
private EntityManagerInterface $entityManager,
private CalDavService $calDavService,
) {}
/**
* @param Task $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
});
$this->calDavService->syncTask($data);
$this->entityManager->flush();
return $result;
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ProjectManagement\Domain\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();
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Controller;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileSource;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function is_resource;
class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly FileSource $fileSource,
private readonly string $uploadDir,
) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): Response
{
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
if (null === $document) {
throw new NotFoundHttpException('Document not found.');
}
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images (except SVG) and PDFs, attachment for everything else.
// SVG is always attachment to prevent XSS via embedded JavaScript.
$inline = 'image/svg+xml' !== $mimeType && (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType);
$disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT;
return $document->isShareLink()
? $this->streamFromShare($document, $mimeType, $disposition)
: $this->streamFromDisk($document, $mimeType, $disposition);
}
private function streamFromDisk(TaskDocument $document, string $mimeType, string $disposition): BinaryFileResponse
{
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('File not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition($disposition, (string) $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
private function streamFromShare(TaskDocument $document, string $mimeType, string $disposition): StreamedResponse
{
try {
$stream = $this->fileSource->read((string) $document->getSharePath());
} catch (ShareNotConfiguredException) {
throw new NotFoundHttpException('Share not configured.');
} catch (ShareConnectionException) {
throw new NotFoundHttpException('File not found on the share.');
}
$response = new StreamedResponse(function () use ($stream): void {
if (is_resource($stream)) {
fpassthru($stream);
fclose($stream);
}
});
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, (string) $document->getOriginalName()));
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Project>
*/
class DoctrineProjectRepository extends ServiceEntityRepository implements ProjectRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Project::class);
}
public function findById(int $id): ?Project
{
return $this->find($id);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskEffort>
*/
class DoctrineTaskEffortRepository extends ServiceEntityRepository implements TaskEffortRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskEffort::class);
}
public function findById(int $id): ?TaskEffort
{
return $this->find($id);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskGroup>
*/
class DoctrineTaskGroupRepository extends ServiceEntityRepository implements TaskGroupRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskGroup::class);
}
public function findById(int $id): ?TaskGroup
{
return $this->find($id);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskPriority>
*/
class DoctrineTaskPriorityRepository extends ServiceEntityRepository implements TaskPriorityRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskPriority::class);
}
public function findById(int $id): ?TaskPriority
{
return $this->find($id);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskRecurrence>
*/
class DoctrineTaskRecurrenceRepository extends ServiceEntityRepository implements TaskRecurrenceRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskRecurrence::class);
}
public function findById(int $id): ?TaskRecurrence
{
return $this->find($id);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Task>
*/
class DoctrineTaskRepository extends ServiceEntityRepository implements TaskRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Task::class);
}
public function findById(int $id): ?Task
{
return $this->find($id);
}
/**
* Returns the max task number for a project, using an advisory lock
* to prevent race conditions when creating tasks concurrently.
*/
public function findMaxNumberByProjectForUpdate(Project $project): int
{
$conn = $this->getEntityManager()->getConnection();
// Use PostgreSQL advisory lock (project ID as lock key) instead of FOR UPDATE
// because FOR UPDATE is not allowed with aggregate functions in PostgreSQL.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:project)',
['project' => $project->getId()],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project',
['project' => $project->getId()],
);
return (int) $result;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskStatus>
*/
class DoctrineTaskStatusRepository extends ServiceEntityRepository implements TaskStatusRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskStatus::class);
}
public function findById(int $id): ?TaskStatus
{
return $this->find($id);
}
public function findFirstNonFinal(): ?TaskStatus
{
return $this->createQueryBuilder('s')
->where('s.isFinal = :final')
->setParameter('final', false)
->orderBy('s.position', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskTag>
*/
class DoctrineTaskTagRepository extends ServiceEntityRepository implements TaskTagRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskTag::class);
}
public function findById(int $id): ?TaskTag
{
return $this->find($id);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Doctrine;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Workflow>
*/
class DoctrineWorkflowRepository extends ServiceEntityRepository implements WorkflowRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Workflow::class);
}
public function findById(int $id): ?Workflow
{
return $this->find($id);
}
public function findDefault(): ?Workflow
{
return $this->findOneBy(['isDefault' => true]);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;
class TaskDocumentListener
{
public function __construct(
private readonly string $uploadDir,
private readonly LoggerInterface $logger,
) {}
public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void
{
// Un lien vers le partage SMB ne possède pas de fichier sur disque : rien à nettoyer.
if ($document->isShareLink()) {
return;
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (file_exists($filePath)) {
if (!unlink($filePath)) {
$this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]);
}
} else {
$this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]);
}
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class TaskNotificationListener
{
/** @var list<array{user: UserInterface, type: string, task: Task}> */
private array $pending = [];
public function __construct(
private readonly Security $security,
private readonly NotifierInterface $notifier,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
$actor = $this->security->getUser();
if (!$actor instanceof UserInterface) {
return;
}
$uow = $args->getObjectManager()->getUnitOfWork();
// Assignation sur une tâche nouvellement créée.
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Task) {
continue;
}
$assignee = $entity->getAssignee();
if ($assignee instanceof UserInterface && $assignee !== $actor) {
$this->pending[] = ['user' => $assignee, 'type' => 'task_assigned', 'task' => $entity];
}
}
// Changement d'assignation sur une tâche existante.
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Task) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['assignee'])) {
continue;
}
$new = $changeSet['assignee'][1];
if ($new instanceof UserInterface && $new !== $actor) {
$this->pending[] = ['user' => $new, 'type' => 'task_assigned', 'task' => $entity];
}
}
// Ajout de collaborateur(s) (tâche nouvelle ou existante).
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$owner = $collection->getOwner();
if (!$owner instanceof Task) {
continue;
}
if ('collaborators' !== $collection->getMapping()->fieldName) {
continue;
}
foreach ($collection->getInsertDiff() as $user) {
if ($user instanceof UserInterface && $user !== $actor) {
$this->pending[] = ['user' => $user, 'type' => 'task_collaborator_added', 'task' => $owner];
}
}
}
}
public function postFlush(PostFlushEventArgs $args): void
{
if ([] === $this->pending) {
return;
}
$pending = $this->pending;
$this->pending = [];
foreach ($pending as $item) {
[$title, $message] = $this->render($item['type'], $item['task']);
$this->notifier->notify($item['user'], $item['type'], $title, $message);
}
}
/**
* @return array{0: string, 1: string}
*/
private function render(string $type, Task $task): array
{
$projectName = $task->getProject()?->getName() ?? '';
$suffix = '' !== $projectName ? sprintf(' — %s', $projectName) : '';
$context = sprintf('« %s »%s', (string) $task->getTitle(), $suffix);
return match ($type) {
'task_assigned' => ['Nouvelle tâche assignée', $context],
'task_collaborator_added' => ['Ajout à une tâche', $context],
default => ['Notification', $context],
};
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\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);
}
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
class CreateProjectTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(
string $name,
string $code,
?string $description = null,
?string $color = null,
?int $clientId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = new Project();
$project->setName($name);
$project->setCode($code);
if (null !== $description) {
$project->setDescription($description);
}
if (null !== $color) {
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$project->setClient($client);
}
$this->entityManager->persist($project);
$this->entityManager->flush();
return json_encode(Serializer::project($project));
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-project', description: 'Permanently delete a project and all its tasks (admin). Irreversible.')]
class DeleteProjectTool
{
public function __construct(
private readonly ProjectRepositoryInterface $projectRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$project = $this->projectRepository->findById($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
$name = $project->getName();
$this->entityManager->remove($project);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Project "%s" deleted.', $name)]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
class GetProjectTool
{
public function __construct(
private readonly ProjectRepositoryInterface $projectRepository,
private readonly TaskRepositoryInterface $taskRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->findById($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
// Count tasks per status
$qb = $this->taskRepository->createQueryBuilder('t')
->select('s.label AS statusLabel, COUNT(t.id) AS taskCount')
->leftJoin('t.status', 's')
->where('t.project = :project')
->setParameter('project', $project)
->groupBy('s.id, s.label')
;
$statusCounts = [];
$totalTasks = 0;
foreach ($qb->getQuery()->getResult() as $row) {
$label = $row['statusLabel'] ?? 'No status';
$count = (int) $row['taskCount'];
$statusCounts[$label] = $count;
$totalTasks += $count;
}
return json_encode(Serializer::project($project) + [
'taskSummary' => $statusCounts,
'totalTasks' => $totalTasks,
]);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
class ListProjectsTool
{
public function __construct(
private readonly ProjectRepositoryInterface $projectRepository,
private readonly Security $security,
) {}
public function __invoke(bool $archived = false): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
return json_encode(array_map(Serializer::project(...), $projects));
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
class UpdateProjectTool
{
public function __construct(
private readonly ProjectRepositoryInterface $projectRepository,
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $code = null,
?string $description = null,
?string $color = null,
?int $clientId = null,
?bool $archived = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->findById($id);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id));
}
if (null !== $name) {
$project->setName($name);
}
if (null !== $code) {
$project->setCode($code);
}
if (null !== $description) {
$project->setDescription($description);
}
if (null !== $color) {
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
$project->setClient($client);
}
if (null !== $archived) {
$project->setArchived($archived);
}
$this->entityManager->flush();
return json_encode(Serializer::project($project));
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Uid\Uuid;
use function sprintf;
use function strlen;
#[McpTool(name: 'add-task-document', description: 'Attach a text document (Markdown by default) to a task by passing its raw content. Optimized for Markdown reports/notes: the content is written verbatim as a UTF-8 file, no base64 needed. The MIME type is inferred from the fileName extension (.md, .txt, .csv, .json, .xml), defaulting to text/markdown.')]
class AddTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepositoryInterface $taskRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $taskId ID of the task to attach the document to
* @param string $content Raw text content of the document (e.g. Markdown)
* @param string $fileName Display name of the document, including extension (defaults to "document.md")
*/
public function __invoke(
int $taskId,
string $content,
string $fileName = 'document.md',
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->findById($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$originalName = '' !== trim($fileName) ? trim($fileName) : 'document.md';
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$mimeType = self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown';
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$storedName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir) && !mkdir($this->uploadDir, 0o775, true) && !is_dir($this->uploadDir)) {
throw new InvalidArgumentException(sprintf('Upload directory "%s" could not be created.', $this->uploadDir));
}
if (false === file_put_contents($this->uploadDir.'/'.$storedName, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($storedName);
$document->setMimeType($mimeType);
$document->setSize($size);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $task->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-task-recurrence', description: 'Create a recurrence pattern for a task. Type: daily, weekly, monthly, yearly. For weekly, provide daysOfWeek array (e.g. ["monday","wednesday"]). For monthly, provide dayOfMonth OR weekOfMonth.')]
class CreateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepositoryInterface $taskRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $taskId,
string $type,
int $interval = 1,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->findById($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$recurrenceType = RecurrenceType::from($type);
$recurrence = new TaskRecurrence();
$recurrence->setType($recurrenceType);
$recurrence->setInterval($interval);
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$task->setRecurrence($recurrence);
$this->entityManager->persist($recurrence);
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
'taskId' => $task->getId(),
]);
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
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. 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(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepositoryInterface $projectRepository,
private readonly TaskRepositoryInterface $taskRepository,
private readonly TaskStatusRepositoryInterface $taskStatusRepository,
private readonly TaskPriorityRepositoryInterface $taskPriorityRepository,
private readonly TaskEffortRepositoryInterface $taskEffortRepository,
private readonly TaskGroupRepositoryInterface $taskGroupRepository,
private readonly TaskTagRepositoryInterface $taskTagRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
* @param int[] $collaboratorIds IDs of the collaborators to attach
*/
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?array $collaboratorIds = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->findById($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$task = new Task();
$task->setProject($project);
$task->setTitle($title);
if (null !== $description) {
$task->setDescription($description);
}
if (null !== $statusId) {
$status = $this->taskStatusRepository->findById($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->findById($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->findById($effortId);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId));
}
$task->setEffort($effort);
}
if (null !== $assigneeId) {
$assignee = $this->userRepository->find($assigneeId);
if (null === $assignee) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId));
}
$task->setAssignee($assignee);
}
if (null !== $groupId) {
$group = $this->taskGroupRepository->findById($groupId);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId));
}
$task->setGroup($group);
}
if (null !== $tagIds) {
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->findById($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$task->addTag($tag);
}
}
if (null !== $collaboratorIds) {
foreach ($collaboratorIds as $collaboratorId) {
$collaborator = $this->userRepository->find($collaboratorId);
if (null === $collaborator) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
}
if (null !== $assigneeId && $collaboratorId === $assigneeId) {
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
}
$task->addCollaborator($collaborator);
}
}
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
$this->entityManager->persist($task);
$this->entityManager->flush();
});
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($project),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-document', description: 'Delete a document attached to a task, permanently. The underlying file is also removed from disk.')]
class DeleteTaskDocumentTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param int $id ID of the task document to delete
*/
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
$taskId = $document->getTask()?->getId();
$originalName = $document->getOriginalName();
$this->entityManager->remove($document);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => sprintf('Document "%s" (ID %d) deleted.', $originalName, $id),
'id' => $id,
'taskId' => $taskId,
'originalName' => $originalName,
]);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-recurrence', description: 'Delete a task recurrence pattern. Nullifies the recurrence on the active task and removes the recurring calendar event.')]
class DeleteTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepositoryInterface $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(int $recurrenceId): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->findById($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
$tasks = $recurrence->getTasks()->toArray();
$eventUidToDelete = null;
foreach ($tasks as $task) {
if (null !== $task->getCalendarEventUid()) {
$eventUidToDelete = $task->getCalendarEventUid();
break;
}
}
foreach ($tasks as $task) {
$task->setRecurrence(null);
}
$this->entityManager->remove($recurrence);
$this->entityManager->flush();
if (null !== $eventUidToDelete) {
$this->calDavService->deleteEvent($eventUidToDelete);
}
return json_encode([
'success' => true,
'message' => sprintf('TaskRecurrence %d deleted.', $recurrenceId),
'tasksUpdated' => count($tasks),
]);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
class DeleteTaskTool
{
public function __construct(
private readonly TaskRepositoryInterface $taskRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->findById($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
$taskCode = $task->getProject()->getCode().'-'.$task->getNumber();
$eventUid = $task->getCalendarEventUid();
$todoUid = $task->getCalendarTodoUid();
$this->entityManager->remove($task);
$this->entityManager->flush();
if (null !== $eventUid) {
$this->calDavService->deleteEvent($eventUid);
}
if (null !== $todoUid) {
$this->calDavService->deleteTodo($todoUid);
}
return json_encode([
'success' => true,
'message' => sprintf('Task %s deleted.', $taskCode),
]);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
class GetTaskTool
{
public function __construct(
private readonly TaskRepositoryInterface $taskRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->findById($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::statusFull($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::group($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tagsWithColor($task->getTags()),
'documents' => Serializer::documents($task->getDocuments()),
'archived' => $task->isArchived(),
]);
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, collaborator, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
class ListTasksTool
{
public function __construct(
private readonly TaskRepositoryInterface $taskRepository,
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to filter by
*/
public function __invoke(
?int $projectId = null,
?int $statusId = null,
?int $assigneeId = null,
?int $collaboratorId = null,
?int $priorityId = null,
?int $groupId = null,
?array $tagIds = null,
bool $archived = false,
int $limit = 100,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t')
->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
->leftJoin('t.project', 'pr')->addSelect('pr')
->leftJoin('t.effort', 'e')->addSelect('e')
->leftJoin('t.group', 'g')->addSelect('g')
->leftJoin('t.tags', 'tg')->addSelect('tg')
->where('t.archived = :archived')
->setParameter('archived', $archived)
->orderBy('t.id', 'DESC')
->setMaxResults($limit)
;
if (null !== $projectId) {
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
}
if (null !== $statusId) {
$qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId);
}
if (null !== $assigneeId) {
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
}
if (null !== $collaboratorId) {
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
}
if (null !== $priorityId) {
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
}
if (null !== $groupId) {
$qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId);
}
$tasks = $qb->getQuery()->getResult();
if (null !== $tagIds) {
$tasks = array_filter($tasks, function ($task) use ($tagIds) {
$taskTagIds = $task->getTags()->map(fn ($t) => $t->getId())->toArray();
return !empty(array_intersect($tagIds, $taskTagIds));
});
}
return json_encode(array_map(fn ($task) => [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'effort' => Serializer::effort($task->getEffort()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
], array_values($tasks)));
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
use function strlen;
#[McpTool(name: 'update-task-document', description: 'Update a document attached to a task: replace its text content and/or rename it. Pass the new raw content (verbatim UTF-8) and/or a new fileName. The MIME type is re-inferred from the fileName extension. At least one of content or fileName must be provided.')]
class UpdateTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $id ID of the task document to update
* @param null|string $content New raw text content of the document (e.g. Markdown). Omit to keep the current content.
* @param null|string $fileName New display name of the document, including extension. Omit to keep the current name.
*/
public function __invoke(
int $id,
?string $content = null,
?string $fileName = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
if (null === $content && null === $fileName) {
throw new InvalidArgumentException('At least one of content or fileName must be provided.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
// Rename: update the display name and re-infer the MIME type from its extension.
if (null !== $fileName) {
$originalName = trim($fileName);
if ('' === $originalName) {
throw new InvalidArgumentException('fileName cannot be empty.');
}
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$document->setOriginalName($originalName);
$document->setMimeType(self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown');
}
// Replace content: overwrite the stored file in place and refresh its size.
if (null !== $content) {
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (false === file_put_contents($filePath, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document->setSize($size);
}
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $document->getTask()?->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-task-recurrence', description: 'Update an existing task recurrence pattern.')]
class UpdateTaskRecurrenceTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRecurrenceRepositoryInterface $taskRecurrenceRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
public function __invoke(
int $recurrenceId,
?string $type = null,
?int $interval = null,
?array $daysOfWeek = null,
?int $dayOfMonth = null,
?int $weekOfMonth = null,
?string $endDate = null,
?int $maxOccurrences = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$recurrence = $this->taskRecurrenceRepository->findById($recurrenceId);
if (null === $recurrence) {
throw new InvalidArgumentException(sprintf('TaskRecurrence with ID %d not found.', $recurrenceId));
}
if (null !== $type) {
$recurrence->setType(RecurrenceType::from($type));
}
if (null !== $interval) {
$recurrence->setInterval($interval);
}
if (null !== $daysOfWeek) {
$recurrence->setDaysOfWeek($daysOfWeek);
}
if (null !== $dayOfMonth) {
$recurrence->setDayOfMonth($dayOfMonth);
}
if (null !== $weekOfMonth) {
$recurrence->setWeekOfMonth($weekOfMonth);
}
if (null !== $endDate) {
$recurrence->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $maxOccurrences) {
$recurrence->setMaxOccurrences($maxOccurrences);
}
$this->entityManager->flush();
foreach ($recurrence->getTasks() as $task) {
$this->calDavService->syncTask($task);
}
$this->entityManager->flush();
return json_encode([
'id' => $recurrence->getId(),
'type' => $recurrence->getType()?->value,
'interval' => $recurrence->getInterval(),
'daysOfWeek' => $recurrence->getDaysOfWeek(),
'dayOfMonth' => $recurrence->getDayOfMonth(),
'weekOfMonth' => $recurrence->getWeekOfMonth(),
'endDate' => $recurrence->getEndDate()?->format('Y-m-d'),
'maxOccurrences' => $recurrence->getMaxOccurrences(),
]);
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use App\Module\ProjectManagement\Infrastructure\Service\CalDavService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
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. 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(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepositoryInterface $taskRepository,
private readonly TaskStatusRepositoryInterface $taskStatusRepository,
private readonly TaskPriorityRepositoryInterface $taskPriorityRepository,
private readonly TaskEffortRepositoryInterface $taskEffortRepository,
private readonly TaskGroupRepositoryInterface $taskGroupRepository,
private readonly TaskTagRepositoryInterface $taskTagRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
private readonly CalDavService $calDavService,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
* @param int[] $collaboratorIds IDs of the collaborators to attach
*/
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?int $statusId = null,
?int $priorityId = null,
?int $effortId = null,
?int $assigneeId = null,
?int $groupId = null,
?array $tagIds = null,
?array $collaboratorIds = null,
?bool $archived = null,
?string $scheduledStart = null,
?string $scheduledEnd = null,
?string $deadline = null,
?bool $syncToCalendar = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->findById($id);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id));
}
if (null !== $title) {
$task->setTitle($title);
}
if (null !== $description) {
$task->setDescription($description);
}
if (null !== $statusId) {
$status = $this->taskStatusRepository->findById($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->findById($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->findById($effortId);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId));
}
$task->setEffort($effort);
}
if (null !== $assigneeId) {
$assignee = $this->userRepository->find($assigneeId);
if (null === $assignee) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId));
}
$task->setAssignee($assignee);
}
if (null !== $groupId) {
$group = $this->taskGroupRepository->findById($groupId);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId));
}
$task->setGroup($group);
}
if (null !== $tagIds) {
// Clear existing tags and set new ones
foreach ($task->getTags()->toArray() as $existingTag) {
$task->removeTag($existingTag);
}
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->findById($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$task->addTag($tag);
}
}
if (null !== $collaboratorIds) {
foreach ($task->getCollaborators()->toArray() as $existing) {
$task->removeCollaborator($existing);
}
$assignee = $task->getAssignee();
foreach ($collaboratorIds as $collaboratorId) {
$collaborator = $this->userRepository->find($collaboratorId);
if (null === $collaborator) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
}
if (null !== $assignee && $collaborator->getId() === $assignee->getId()) {
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
}
$task->addCollaborator($collaborator);
}
}
if (null !== $archived) {
$task->setArchived($archived);
}
if (null !== $scheduledStart) {
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
}
if (null !== $scheduledEnd) {
$task->setScheduledEnd(new DateTimeImmutable($scheduledEnd));
}
if (null !== $deadline) {
$task->setDeadline(new DateTimeImmutable($deadline));
}
if (null !== $syncToCalendar) {
$task->setSyncToCalendar($syncToCalendar);
}
$this->entityManager->flush();
$this->calDavService->syncTask($task);
$this->entityManager->flush();
return json_encode([
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => Serializer::status($task->getStatus()),
'priority' => Serializer::priority($task->getPriority()),
'effort' => Serializer::effort($task->getEffort()),
'assignee' => Serializer::user($task->getAssignee()),
'collaborators' => Serializer::users($task->getCollaborators()),
'group' => Serializer::groupRef($task->getGroup()),
'project' => Serializer::projectRef($task->getProject()),
'tags' => Serializer::tags($task->getTags()),
'archived' => $task->isArchived(),
'scheduledStart' => $task->getScheduledStart()?->format('c'),
'scheduledEnd' => $task->getScheduledEnd()?->format('c'),
'deadline' => $task->getDeadline()?->format('c'),
'syncToCalendar' => $task->isSyncToCalendar(),
]);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-effort', description: 'Create a global task effort level (label only).')]
class CreateEffortTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = new TaskEffort();
$effort->setLabel($label);
$this->entityManager->persist($effort);
$this->entityManager->flush();
return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
class CreateGroupTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepositoryInterface $projectRepository,
private readonly Security $security,
) {}
public function __invoke(
int $projectId,
string $title,
?string $description = null,
?string $color = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->findById($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$group = new TaskGroup();
$group->setProject($project);
$group->setTitle($title);
if (null !== $description) {
$group->setDescription($description);
}
if (null !== $color) {
$group->setColor($color);
}
$this->entityManager->persist($group);
$this->entityManager->flush();
return json_encode(Serializer::groupFull($group));
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-priority', description: 'Create a global task priority (label + color).')]
class CreatePriorityTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = new TaskPriority();
$priority->setLabel($label);
if (null !== $color) {
$priority->setColor($color);
}
$this->entityManager->persist($priority);
$this->entityManager->flush();
return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Enum\StatusCategory;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-status', description: 'Create a task status inside a workflow (admin). Statuses are NOT global: each belongs to a workflow (use list-workflows for IDs). category = todo|in_progress|blocked|review|done.')]
class CreateStatusTool
{
public function __construct(
private readonly WorkflowRepositoryInterface $workflowRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $workflowId,
string $label,
string $category,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$workflow = $this->workflowRepository->findById($workflowId);
if (null === $workflow) {
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
}
$categoryEnum = StatusCategory::tryFrom($category)
?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category));
$status = new TaskStatus();
$status->setWorkflow($workflow);
$status->setLabel($label);
$status->setCategory($categoryEnum);
if (null !== $color) {
$status->setColor($color);
}
if (null !== $position) {
$status->setPosition($position);
}
if (null !== $isFinal) {
$status->setIsFinal($isFinal);
}
$this->entityManager->persist($status);
$this->entityManager->flush();
return json_encode([
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'position' => $status->getPosition(),
'isFinal' => $status->getIsFinal(),
'category' => $status->getCategory()->value,
'workflowId' => $workflow->getId(),
]);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-tag', description: 'Create a global task tag. Tags are shared across all projects.')]
class CreateTagTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(string $label, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = new TaskTag();
$tag->setLabel($label);
if (null !== $color) {
$tag->setColor($color);
}
$this->entityManager->persist($tag);
$this->entityManager->flush();
return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-effort', description: 'Delete a task effort level.')]
class DeleteEffortTool
{
public function __construct(
private readonly TaskEffortRepositoryInterface $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = $this->effortRepository->findById($id);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id));
}
$label = $effort->getLabel();
$this->entityManager->remove($effort);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Effort "%s" deleted.', $label)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-group', description: 'Delete a task group. Tasks in the group are not deleted; they become ungrouped.')]
class DeleteGroupTool
{
public function __construct(
private readonly TaskGroupRepositoryInterface $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$group = $this->taskGroupRepository->findById($id);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id));
}
$title = $group->getTitle();
$this->entityManager->remove($group);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Group "%s" deleted.', $title)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-priority', description: 'Delete a task priority.')]
class DeletePriorityTool
{
public function __construct(
private readonly TaskPriorityRepositoryInterface $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = $this->priorityRepository->findById($id);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id));
}
$label = $priority->getLabel();
$this->entityManager->remove($priority);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Priority "%s" deleted.', $label)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-status', description: 'Delete a task status from its workflow (admin). Fails at the database level if tasks still use it.')]
class DeleteStatusTool
{
public function __construct(
private readonly TaskStatusRepositoryInterface $statusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$status = $this->statusRepository->findById($id);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id));
}
$label = $status->getLabel();
$this->entityManager->remove($status);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Status "%s" deleted.', $label)]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-tag', description: 'Delete a global task tag. It is removed from all tasks that use it.')]
class DeleteTagTool
{
public function __construct(
private readonly TaskTagRepositoryInterface $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = $this->tagRepository->findById($id);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id));
}
$label = $tag->getLabel();
$this->entityManager->remove($tag);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Tag "%s" deleted.', $label)]);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
class ListEffortsTool
{
public function __construct(
private readonly TaskEffortRepositoryInterface $taskEffortRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($e) => [
'id' => $e->getId(),
'label' => $e->getLabel(),
], $efforts));
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
class ListGroupsTool
{
public function __construct(
private readonly TaskGroupRepositoryInterface $taskGroupRepository,
private readonly Security $security,
) {}
public function __invoke(?int $projectId = null, bool $archived = false): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$criteria = ['archived' => $archived];
if (null !== $projectId) {
$criteria['project'] = $projectId;
}
$groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']);
return json_encode(array_map(Serializer::groupFull(...), $groups));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
class ListPrioritiesTool
{
public function __construct(
private readonly TaskPriorityRepositoryInterface $taskPriorityRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($p) => [
'id' => $p->getId(),
'label' => $p->getLabel(),
'color' => $p->getColor(),
], $priorities));
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
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 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 TaskStatusRepositoryInterface $taskStatusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(?int $projectId = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
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(),
'category' => $s->getCategory()->value,
'workflowId' => $s->getWorkflow()?->getId(),
], $statuses));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
class ListTagsTool
{
public function __construct(
private readonly TaskTagRepositoryInterface $taskTagRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
], $tags));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-effort', description: 'Rename a task effort level.')]
class UpdateEffortTool
{
public function __construct(
private readonly TaskEffortRepositoryInterface $effortRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, string $label): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$effort = $this->effortRepository->findById($id);
if (null === $effort) {
throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $id));
}
$effort->setLabel($label);
$this->entityManager->flush();
return json_encode(['id' => $effort->getId(), 'label' => $effort->getLabel()]);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
class UpdateGroupTool
{
public function __construct(
private readonly TaskGroupRepositoryInterface $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $title = null,
?string $description = null,
?string $color = null,
?bool $archived = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$group = $this->taskGroupRepository->findById($id);
if (null === $group) {
throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id));
}
if (null !== $title) {
$group->setTitle($title);
}
if (null !== $description) {
$group->setDescription($description);
}
if (null !== $color) {
$group->setColor($color);
}
if (null !== $archived) {
$group->setArchived($archived);
}
$this->entityManager->flush();
return json_encode(Serializer::groupFull($group));
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-priority', description: 'Update a task priority. Only provided fields change.')]
class UpdatePriorityTool
{
public function __construct(
private readonly TaskPriorityRepositoryInterface $priorityRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priority = $this->priorityRepository->findById($id);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $id));
}
if (null !== $label) {
$priority->setLabel($label);
}
if (null !== $color) {
$priority->setColor($color);
}
$this->entityManager->flush();
return json_encode(['id' => $priority->getId(), 'label' => $priority->getLabel(), 'color' => $priority->getColor()]);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Enum\StatusCategory;
use App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-status', description: 'Update a task status (admin). Only provided fields change. category = todo|in_progress|blocked|review|done.')]
class UpdateStatusTool
{
public function __construct(
private readonly TaskStatusRepositoryInterface $statusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $label = null,
?string $category = null,
?string $color = null,
?int $position = null,
?bool $isFinal = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$status = $this->statusRepository->findById($id);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $id));
}
if (null !== $label) {
$status->setLabel($label);
}
if (null !== $category) {
$status->setCategory(
StatusCategory::tryFrom($category)
?? throw new InvalidArgumentException(sprintf('Unknown status category "%s".', $category)),
);
}
if (null !== $color) {
$status->setColor($color);
}
if (null !== $position) {
$status->setPosition($position);
}
if (null !== $isFinal) {
$status->setIsFinal($isFinal);
}
$this->entityManager->flush();
return json_encode([
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'position' => $status->getPosition(),
'isFinal' => $status->getIsFinal(),
'category' => $status->getCategory()->value,
'workflowId' => $status->getWorkflow()?->getId(),
]);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\TaskMeta;
use App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-tag', description: 'Update a task tag. Only provided fields change.')]
class UpdateTagTool
{
public function __construct(
private readonly TaskTagRepositoryInterface $tagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id, ?string $label = null, ?string $color = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tag = $this->tagRepository->findById($id);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $id));
}
if (null !== $label) {
$tag->setLabel($label);
}
if (null !== $color) {
$tag->setColor($color);
}
$this->entityManager->flush();
return json_encode(['id' => $tag->getId(), 'label' => $tag->getLabel(), 'color' => $tag->getColor()]);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Workflow;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
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 WorkflowRepositoryInterface $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));
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Workflow;
use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\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,
]);
}
}
@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Service;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Repository\ZimbraConfigurationRepository;
use App\Service\TokenEncryptor;
use DateTimeZone;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use const ENT_HTML5;
use const ENT_QUOTES;
final class CalDavService
{
public function __construct(
private readonly ZimbraConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {}
public function isConfigured(): bool
{
$config = $this->configRepository->findSingleton();
return null !== $config && $config->isEnabled();
}
public function testConnection(): bool
{
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isEnabled()) {
return false;
}
try {
$response = $this->httpClient->request('PROPFIND', $this->getCalendarUrl(), [
'timeout' => 5,
'auth_basic' => [
$config->getUsername(),
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
],
'headers' => [
'Depth' => '0',
],
]);
$statusCode = $response->getStatusCode();
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
} catch (Throwable $e) {
$this->logger->error('CalDAV connection test failed: '.$e->getMessage());
return false;
}
}
public function createEvent(Task $task): ?string
{
$uid = $this->generateUid();
$calendar = $this->buildEventCalendar($task, $uid);
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
return null;
}
return $uid;
}
public function createTodo(Task $task): ?string
{
$uid = $this->generateUid();
$calendar = $this->buildTodoCalendar($task, $uid);
if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) {
return null;
}
return $uid;
}
public function updateEvent(Task $task): bool
{
$uid = $task->getCalendarEventUid();
if (null === $uid) {
return false;
}
$calendar = $this->buildEventCalendar($task, $uid);
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
}
public function updateTodo(Task $task): bool
{
$uid = $task->getCalendarTodoUid();
if (null === $uid) {
return false;
}
$calendar = $this->buildTodoCalendar($task, $uid);
return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize());
}
public function deleteEvent(?string $uid): bool
{
if (null === $uid) {
return true;
}
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
}
public function deleteTodo(?string $uid): bool
{
if (null === $uid) {
return true;
}
return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics');
}
public function syncTask(Task $task): void
{
if (!$task->isSyncToCalendar()) {
$this->deleteEvent($task->getCalendarEventUid());
$this->deleteTodo($task->getCalendarTodoUid());
$task->setCalendarEventUid(null);
$task->setCalendarTodoUid(null);
$task->setCalendarSyncError(null);
return;
}
$hasStart = null !== $task->getScheduledStart();
$hasDeadline = null !== $task->getDeadline();
if (!$hasStart && !$hasDeadline) {
return;
}
$syncError = null;
if ($hasStart) {
if (null !== $task->getCalendarEventUid()) {
$success = $this->updateEvent($task);
} else {
$uid = $this->createEvent($task);
if (null !== $uid) {
$task->setCalendarEventUid($uid);
$success = true;
} else {
$success = false;
}
}
if (!$success) {
$syncError = 'Failed to sync event to calendar.';
}
} elseif (null !== $task->getCalendarEventUid()) {
$this->deleteEvent($task->getCalendarEventUid());
$task->setCalendarEventUid(null);
}
if ($hasDeadline) {
if (null !== $task->getCalendarTodoUid()) {
$success = $this->updateTodo($task);
} else {
$uid = $this->createTodo($task);
if (null !== $uid) {
$task->setCalendarTodoUid($uid);
$success = true;
} else {
$success = false;
}
}
if (!$success) {
$syncError = ($syncError ?? '').'Failed to sync todo to calendar.';
}
} elseif (null !== $task->getCalendarTodoUid()) {
$this->deleteTodo($task->getCalendarTodoUid());
$task->setCalendarTodoUid(null);
}
$task->setCalendarSyncError($syncError);
}
private function buildEventCalendar(Task $task, string $uid): VCalendar
{
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VEVENT', [
'UID' => $uid,
'SUMMARY' => $summary,
'DTSTART' => $task->getScheduledStart(),
'DTEND' => $task->getScheduledEnd(),
'DESCRIPTION' => $description,
]);
$recurrence = $task->getRecurrence();
if (null !== $recurrence) {
$vevent = $vcalendar->VEVENT;
$vevent->add('RRULE', $this->buildRRule($recurrence));
}
return $vcalendar;
}
private function buildTodoCalendar(Task $task, string $uid): VCalendar
{
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VTODO', [
'UID' => $uid,
'SUMMARY' => $summary,
'DUE' => $task->getDeadline(),
'DESCRIPTION' => $description,
]);
return $vcalendar;
}
private function buildRRule(TaskRecurrence $recurrence): string
{
$parts = [];
$interval = $recurrence->getInterval();
match ($recurrence->getType()) {
RecurrenceType::Daily => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
RecurrenceType::Weekly => (function () use (&$parts, $interval, $recurrence): void {
$dayMap = $this->getDayMap();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
$byDay = implode(',', array_map(fn (string $d) => $dayMap[$d] ?? $d, $daysOfWeek));
$rule = 'FREQ=WEEKLY;INTERVAL='.$interval;
if ('' !== $byDay) {
$rule .= ';BYDAY='.$byDay;
}
$parts[] = $rule;
})(),
RecurrenceType::Monthly => (function () use (&$parts, $interval, $recurrence): void {
$dayOfMonth = $recurrence->getDayOfMonth();
$weekOfMonth = $recurrence->getWeekOfMonth();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
if (null !== $dayOfMonth) {
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYMONTHDAY='.$dayOfMonth;
} elseif (null !== $weekOfMonth && [] !== $daysOfWeek) {
$dayMap = $this->getDayMap();
$day = $dayMap[$daysOfWeek[0]] ?? $daysOfWeek[0];
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYDAY='.$weekOfMonth.$day;
} else {
$parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval;
}
})(),
RecurrenceType::Yearly => $parts[] = 'FREQ=YEARLY;INTERVAL='.$interval,
default => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval,
};
$rule = $parts[0] ?? 'FREQ=DAILY;INTERVAL=1';
$endDate = $recurrence->getEndDate();
$maxOccurrences = $recurrence->getMaxOccurrences();
if (null !== $endDate) {
$rule .= ';UNTIL='.$endDate->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z');
} elseif (null !== $maxOccurrences) {
$rule .= ';COUNT='.$maxOccurrences;
}
return $rule;
}
private function getCalendarUrl(): string
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
return '';
}
return rtrim((string) $config->getServerUrl(), '/').'/'.ltrim((string) $config->getCalendarPath(), '/').'/';
}
private function makeRequest(string $method, string $url, ?string $body = null, string $contentType = 'text/calendar'): bool
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
return false;
}
try {
$options = [
'timeout' => 5,
'auth_basic' => [
$config->getUsername(),
$this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()),
],
];
if (null !== $body) {
$options['headers'] = ['Content-Type' => $contentType];
$options['body'] = $body;
}
$response = $this->httpClient->request($method, $url, $options);
$statusCode = $response->getStatusCode();
return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode;
} catch (Throwable $e) {
$this->logger->error(sprintf('CalDAV %s request to %s failed: %s', $method, $url, $e->getMessage()));
return false;
}
}
private function generateUid(): string
{
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
}
private function descriptionToPlainText(?string $value): string
{
if (null === $value || '' === $value) {
return '';
}
$stripped = strip_tags($value);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
}
/** @return array<string, string> */
private function getDayMap(): array
{
return [
'monday' => 'MO',
'tuesday' => 'TU',
'wednesday' => 'WE',
'thursday' => 'TH',
'friday' => 'FR',
'saturday' => 'SA',
'sunday' => 'SU',
];
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Service;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use DateTimeImmutable;
final class RecurrenceCalculator
{
public function getNextDate(Task $task): ?DateTimeImmutable
{
$recurrence = $task->getRecurrence();
$scheduledStart = $task->getScheduledStart();
if (null === $recurrence || null === $scheduledStart) {
return null;
}
if ($this->hasReachedEnd($recurrence)) {
return null;
}
$type = $recurrence->getType();
$interval = $recurrence->getInterval();
return match ($type) {
RecurrenceType::Daily => $this->nextDaily($scheduledStart, $interval),
RecurrenceType::Weekly => $this->nextWeekly($scheduledStart, $interval, $recurrence->getDaysOfWeek() ?? []),
RecurrenceType::Monthly => $this->nextMonthly($scheduledStart, $interval, $recurrence),
RecurrenceType::Yearly => $this->nextYearly($scheduledStart, $interval),
default => null,
};
}
public function getNextEnd(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
{
$scheduledStart = $task->getScheduledStart();
$scheduledEnd = $task->getScheduledEnd();
if (null === $scheduledEnd || null === $scheduledStart) {
return null;
}
$duration = $scheduledStart->diff($scheduledEnd);
return $nextStart->add($duration);
}
public function getNextDeadline(Task $task, DateTimeImmutable $nextStart): ?DateTimeImmutable
{
$scheduledStart = $task->getScheduledStart();
$deadline = $task->getDeadline();
if (null === $deadline || null === $scheduledStart) {
return null;
}
$offset = $scheduledStart->diff($deadline);
return $nextStart->add($offset);
}
public function hasReachedEnd(TaskRecurrence $recurrence): bool
{
$maxOccurrences = $recurrence->getMaxOccurrences();
if (null !== $maxOccurrences && $recurrence->getOccurrenceCount() >= $maxOccurrences) {
return true;
}
$endDate = $recurrence->getEndDate();
if (null !== $endDate) {
$today = new DateTimeImmutable('today');
if ($endDate < $today) {
return true;
}
}
return false;
}
private function nextDaily(DateTimeImmutable $start, int $interval): DateTimeImmutable
{
return $start->modify(sprintf('+%d days', $interval));
}
private function nextWeekly(DateTimeImmutable $start, int $interval, array $daysOfWeek): DateTimeImmutable
{
$candidate = $start->modify(sprintf('+%d weeks', $interval));
if ([] === $daysOfWeek) {
return $candidate;
}
$dayNumberMap = $this->getDayNumberMap();
// Collect target day numbers
$targetDayNumbers = [];
foreach ($daysOfWeek as $day) {
if (isset($dayNumberMap[$day])) {
$targetDayNumbers[] = $dayNumberMap[$day];
}
}
if ([] === $targetDayNumbers) {
return $candidate;
}
sort($targetDayNumbers);
// Find the first matching day in the week starting from candidate
$weekStart = (int) $candidate->format('N'); // 1=Mon, 7=Sun
$candidateDayNum = $weekStart;
foreach ($targetDayNumbers as $targetDay) {
if ($targetDay >= $candidateDayNum) {
$diff = $targetDay - $candidateDayNum;
return $candidate->modify(sprintf('+%d days', $diff));
}
}
// Wrap to next week's first matching day
$diff = 7 - $candidateDayNum + $targetDayNumbers[0];
return $candidate->modify(sprintf('+%d days', $diff));
}
private function nextMonthly(DateTimeImmutable $start, int $interval, TaskRecurrence $recurrence): DateTimeImmutable
{
$dayOfMonth = $recurrence->getDayOfMonth();
$weekOfMonth = $recurrence->getWeekOfMonth();
$daysOfWeek = $recurrence->getDaysOfWeek() ?? [];
if (null !== $dayOfMonth) {
return $this->nextMonthlyByDayOfMonth($start, $interval, $dayOfMonth);
}
if (null !== $weekOfMonth && [] !== $daysOfWeek) {
return $this->nextMonthlyByWeekOfMonth($start, $interval, $weekOfMonth, $daysOfWeek[0]);
}
// Fallback: same day of month, interval months ahead
return $this->nextMonthlyByDayOfMonth($start, $interval, (int) $start->format('j'));
}
private function nextMonthlyByDayOfMonth(DateTimeImmutable $start, int $interval, int $dayOfMonth): DateTimeImmutable
{
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$month += $interval;
while ($month > 12) {
$month -= 12;
++$year;
}
// Handle month overflow (e.g. dayOfMonth=31 in a 30-day month)
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
$day = min($dayOfMonth, $daysInMonth);
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$day,
$start->format('H:i:s'),
));
}
private function nextMonthlyByWeekOfMonth(DateTimeImmutable $start, int $interval, int $weekOfMonth, string $dayName): DateTimeImmutable
{
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$month += $interval;
while ($month > 12) {
$month -= 12;
++$year;
}
$dayNumberMap = $this->getDayNumberMap();
$targetDayNum = $dayNumberMap[$dayName] ?? 1;
// Find the Nth occurrence of the target weekday in the target month
$firstOfMonth = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
$firstDayNum = (int) $firstOfMonth->format('N'); // 1=Mon, 7=Sun
// Days until first occurrence of target weekday
$daysToFirst = ($targetDayNum - $firstDayNum + 7) % 7;
$dayOfMonth = 1 + $daysToFirst + ($weekOfMonth - 1) * 7;
// Handle overflow (e.g. 5th occurrence that doesn't exist)
$daysInMonth = (int) $firstOfMonth->format('t');
if ($dayOfMonth > $daysInMonth) {
// Fall back to last occurrence
$dayOfMonth -= 7;
}
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$dayOfMonth,
$start->format('H:i:s'),
));
}
private function nextYearly(DateTimeImmutable $start, int $interval): DateTimeImmutable
{
$year = (int) $start->format('Y') + $interval;
$month = (int) $start->format('n');
$day = (int) $start->format('j');
// Handle leap year: Feb 29 → Feb 28
$daysInMonth = (int) new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month))->format('t');
$day = min($day, $daysInMonth);
return new DateTimeImmutable(sprintf(
'%d-%02d-%02d %s',
$year,
$month,
$day,
$start->format('H:i:s'),
));
}
/** @return array<string, int> */
private function getDayNumberMap(): array
{
return [
'monday' => 1,
'tuesday' => 2,
'wednesday' => 3,
'thursday' => 4,
'friday' => 5,
'saturday' => 6,
'sunday' => 7,
];
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement;
use App\Shared\Domain\Module\ModuleInterface;
final class ProjectManagementModule implements ModuleInterface
{
public static function id(): string
{
return 'project-management';
}
public static function label(): string
{
return 'Projets & Tâches';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module ProjectManagement (2.2).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'project-management.projects.view', 'label' => 'Voir les projets'],
['code' => 'project-management.projects.manage', 'label' => 'Gérer les projets'],
['code' => 'project-management.tasks.view', 'label' => 'Voir les tâches'],
['code' => 'project-management.tasks.manage', 'label' => 'Gérer les tâches'],
];
}
}