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:
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
+46
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+66
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+88
@@ -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));
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user