Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s

## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
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: 'cancel-absence-request', description: 'Cancel an absence request. A PENDING request releases its reserved days; an APPROVED request can only be cancelled by an admin and credits the taken days back. Already cancelled/rejected requests cannot be cancelled.')]
class CancelAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$status = $request->getStatus();
if (AbsenceStatus::Pending === $status) {
$this->balanceService->release($request, false);
} elseif (AbsenceStatus::Approved === $status) {
if (!$isAdmin) {
throw new AccessDeniedException('Only an admin can cancel an approved request.');
}
$this->balanceService->release($request, true);
} else {
throw new InvalidArgumentException('This request can no longer be cancelled.');
}
$request->setStatus(AbsenceStatus::Cancelled);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
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-absence-request', description: 'Create an absence request on behalf of an employee (userId). Validates active policy + no overlap, computes deducted working days, sets status=pending and reserves the days in the pending balance. type: cp|mariage_pacs|conge_parental|deces|maladie. Dates YYYY-MM-DD. halfDay (matin|apres_midi) on a boundary subtracts 0.5.')]
class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(
int $userId,
string $type,
string $startDate,
string $endDate,
?string $startHalfDay = null,
?string $endHalfDay = null,
?string $reason = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = $this->userRepository->find($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
$typeEnum = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
$start = new DateTimeImmutable($startDate);
$end = new DateTimeImmutable($endDate);
if ($end < $start) {
throw new InvalidArgumentException('End date must be on or after start date.');
}
$startHd = null !== $startHalfDay
? (HalfDay::tryFrom($startHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $startHalfDay)))
: null;
$endHd = null !== $endHalfDay
? (HalfDay::tryFrom($endHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $endHalfDay)))
: null;
$policy = $this->policyRepository->findOneByType($typeEnum);
if (null === $policy || !$policy->isActive()) {
throw new InvalidArgumentException('This absence type is not available.');
}
// Bereavement has no fixed entitlement: the relationship to the deceased
// drives the legal number of days, so the reason is mandatory.
if (AbsenceType::Bereavement === $typeEnum && '' === trim((string) $reason)) {
throw new InvalidArgumentException('A reason (relationship to the deceased) is required for bereavement leave.');
}
if ($this->requestRepository->hasOverlap($user, $start, $end)) {
throw new InvalidArgumentException('This request overlaps an existing absence.');
}
$countedDays = $this->calculator->countWorkingDays($start, $end, $startHd, $endHd, $policy->isCountWorkingDaysOnly());
if ($countedDays <= 0.0) {
throw new InvalidArgumentException('The selected range contains no working day.');
}
$request = new AbsenceRequest();
$request->setUser($user);
$request->setType($typeEnum);
$request->setStartDate($start);
$request->setEndDate($end);
$request->setStartHalfDay($startHd);
$request->setEndHalfDay($endHd);
$request->setReason($reason);
$request->setCountedDays($countedDays);
$request->setStatus(AbsenceStatus::Pending);
$request->setRejectionReason(null);
$request->setCreatedAt(new DateTimeImmutable());
$this->entityManager->persist($request);
$this->balanceService->reservePending($request);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Repository\AbsenceRequestRepository;
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-absence-request', description: 'Permanently delete an absence request (admin). Does not adjust balances — use cancel-absence-request first if the days must be credited back.')]
class DeleteAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
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.');
}
$request = $this->requestRepository->find($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
$this->entityManager->remove($request);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('AbsenceRequest %d deleted.', $id)]);
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
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-absence-request', description: 'Get a single absence request by ID.')]
class GetAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-balances', description: 'List leave balances. Optional filters: userId, type (cp, mariage_pacs, conge_parental, deces, maladie), period (e.g. "2025-2026" or "2025").')]
class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(?int $userId = null, ?string $type = null, ?string $period = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$criteria = [];
if (null !== $userId) {
$user = $this->userRepository->find($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
$criteria['user'] = $user;
}
if (null !== $type) {
$criteria['type'] = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
}
if (null !== $period) {
$criteria['period'] = $period;
}
$balances = $this->balanceRepository->findBy($criteria, ['period' => 'DESC']);
return json_encode(array_map(Serializer::absenceBalance(...), $balances));
}
}
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-absence-policies', description: 'List all absence policies (one per absence type) with their rules: days/year, days/event, notice period, working-days mode, active flag.')]
class ListAbsencePoliciesTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$policies = $this->policyRepository->findBy([], ['type' => 'ASC']);
return json_encode(array_map(Serializer::absencePolicy(...), $policies));
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'list-absence-requests', description: 'List absence requests. Optional filters: userId, status (pending, approved, rejected, cancelled), type (cp, mariage_pacs, conge_parental, deces, maladie), from/to (YYYY-MM-DD, overlap window). Without userId returns all employees.')]
class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $userId = null,
?string $status = null,
?string $type = null,
?string $from = null,
?string $to = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = null;
if (null !== $userId) {
$user = $this->userRepository->find($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
$statusEnum = null;
if (null !== $status) {
$statusEnum = AbsenceStatus::tryFrom($status)
?? throw new InvalidArgumentException(sprintf('Unknown status "%s".', $status));
}
$typeEnum = null;
if (null !== $type) {
$typeEnum = AbsenceType::tryFrom($type)
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
}
$requests = $this->requestRepository->findFiltered(
$user,
$statusEnum,
$typeEnum,
null !== $from ? new DateTimeImmutable($from) : null,
null !== $to ? new DateTimeImmutable($to) : null,
);
return json_encode(array_map(Serializer::absenceRequest(...), $requests));
}
}
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
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 assert;
use function sprintf;
#[McpTool(name: 'review-absence-request', description: 'Approve or reject a PENDING absence request (admin). decision = "approve" or "reject"; rejectionReason is required when rejecting. Approving moves the days from pending to taken; rejecting releases the reserved days.')]
class ReviewAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
public function __invoke(int $id, string $decision, ?string $rejectionReason = null): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
if (!in_array($decision, ['approve', 'reject'], true)) {
throw new InvalidArgumentException('decision must be "approve" or "reject".');
}
$request = $this->requestRepository->find($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
if (AbsenceStatus::Pending !== $request->getStatus()) {
throw new InvalidArgumentException('Only a pending request can be reviewed.');
}
$admin = $this->security->getUser();
assert($admin instanceof User);
if ('approve' === $decision) {
// Never let an approval push the balance below zero (CP only).
$available = $this->balanceService->availableForRequest($request);
if (null !== $available && $request->getCountedDays() > $available + 1e-9) {
throw new InvalidArgumentException(sprintf(
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
$request->getCountedDays(),
$available,
));
}
$request->setStatus(AbsenceStatus::Approved);
$request->setRejectionReason(null);
$this->balanceService->applyApproval($request);
} else {
if (null === $rejectionReason || '' === trim($rejectionReason)) {
throw new InvalidArgumentException('A reason is required when rejecting a request.');
}
$request->setStatus(AbsenceStatus::Rejected);
$request->setRejectionReason($rejectionReason);
$this->balanceService->release($request, false);
}
$request->setReviewedAt(new DateTimeImmutable());
$request->setReviewedBy($admin);
$this->entityManager->flush();
return json_encode(Serializer::absenceRequest($request));
}
}
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
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-absence-balance', description: 'Manually adjust a leave balance (admin regularisation). Only provided buckets change: acquired (N-1), acquiring (N), taken.')]
class UpdateAbsenceBalanceTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $acquired = null,
?float $acquiring = null,
?float $taken = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$balance = $this->balanceRepository->find($id);
if (null === $balance) {
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
}
if (null !== $acquired) {
$balance->setAcquired($acquired);
}
if (null !== $acquiring) {
$balance->setAcquiring($acquiring);
}
if (null !== $taken) {
$balance->setTaken($taken);
}
$this->entityManager->flush();
return json_encode(Serializer::absenceBalance($balance));
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
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-absence-policy', description: 'Update an absence policy (admin). Only provided fields change.')]
class UpdateAbsencePolicyTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?float $daysPerYear = null,
?float $daysPerEvent = null,
?bool $justificationRequired = null,
?int $noticeDays = null,
?bool $countWorkingDaysOnly = null,
?bool $active = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$policy = $this->policyRepository->find($id);
if (null === $policy) {
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
}
if (null !== $daysPerYear) {
$policy->setDaysPerYear($daysPerYear);
}
if (null !== $daysPerEvent) {
$policy->setDaysPerEvent($daysPerEvent);
}
if (null !== $justificationRequired) {
$policy->setJustificationRequired($justificationRequired);
}
if (null !== $noticeDays) {
$policy->setNoticeDays($noticeDays);
}
if (null !== $countWorkingDaysOnly) {
$policy->setCountWorkingDaysOnly($countWorkingDaysOnly);
}
if (null !== $active) {
$policy->setActive($active);
}
$this->entityManager->flush();
return json_encode(Serializer::absencePolicy($policy));
}
}
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Entity\Project;
use App\Mcp\Tool\Serializer;
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));
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Repository\ProjectRepository;
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 ProjectRepository $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->find($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)]);
}
}
-61
View File
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
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 ProjectRepository $projectRepository,
private readonly TaskRepository $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->find($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,
]);
}
}
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
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 ProjectRepository $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));
}
}
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
use App\Repository\ProjectRepository;
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 ProjectRepository $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->find($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));
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Entity\Client;
use App\Mcp\Tool\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'create-client', description: 'Create a client (admin). Only name is required.')]
class CreateClientTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
string $name,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = new Client();
$client->setName($name);
$client->setEmail($email);
$client->setPhone($phone);
$client->setStreet($street);
$client->setCity($city);
$client->setPostalCode($postalCode);
$this->entityManager->persist($client);
$this->entityManager->flush();
return json_encode(Serializer::client($client));
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
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: 'delete-client', description: 'Delete a client (admin). Fails if the client still has projects attached.')]
class DeleteClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
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.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
$name = $client->getName();
$this->entityManager->remove($client);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Client "%s" deleted.', $name)]);
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\ClientRepository;
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-client', description: 'Get a client by ID with full contact details.')]
class GetClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
return json_encode(Serializer::client($client));
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
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-user', description: 'Get a user by ID with full HR profile (employee flag, hire/end date, contract, work-time ratio, leave entitlement, reference period, family situation).')]
class GetUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$user = $this->userRepository->find($id);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
}
return json_encode(Serializer::userFull($user));
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
class ListClientsTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($client) => [
'id' => $client->getId(),
'name' => $client->getName(),
'email' => $client->getEmail(),
], $clients));
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
return json_encode(array_map(fn ($user) => [
'id' => $user->getId(),
'username' => $user->getUsername(),
], $users));
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Mcp\Tool\Serializer;
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-client', description: 'Update a client (admin). Only provided fields change.')]
class UpdateClientTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?string $name = null,
?string $email = null,
?string $phone = null,
?string $street = null,
?string $city = null,
?string $postalCode = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$client = $this->clientRepository->find($id);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
}
if (null !== $name) {
$client->setName($name);
}
if (null !== $email) {
$client->setEmail($email);
}
if (null !== $phone) {
$client->setPhone($phone);
}
if (null !== $street) {
$client->setStreet($street);
}
if (null !== $city) {
$client->setCity($city);
}
if (null !== $postalCode) {
$client->setPostalCode($postalCode);
}
$this->entityManager->flush();
return json_encode(Serializer::client($client));
}
}
-80
View File
@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Enum\ContractType;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
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-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
class UpdateUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
int $id,
?bool $isEmployee = null,
?string $hireDate = null,
?string $endDate = null,
?string $contractType = null,
?float $workTimeRatio = null,
?float $annualLeaveDays = null,
?string $referencePeriodStart = null,
?float $initialLeaveBalance = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$user = $this->userRepository->find($id);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
}
if (null !== $isEmployee) {
$user->setIsEmployee($isEmployee);
}
if (null !== $hireDate) {
$user->setHireDate(new DateTimeImmutable($hireDate));
}
if (null !== $endDate) {
$user->setEndDate(new DateTimeImmutable($endDate));
}
if (null !== $contractType) {
$user->setContractType(
ContractType::tryFrom($contractType)
?? throw new InvalidArgumentException(sprintf('Unknown contract type "%s".', $contractType)),
);
}
if (null !== $workTimeRatio) {
$user->setWorkTimeRatio($workTimeRatio);
}
if (null !== $annualLeaveDays) {
$user->setAnnualLeaveDays($annualLeaveDays);
}
if (null !== $referencePeriodStart) {
$user->setReferencePeriodStart($referencePeriodStart);
}
if (null !== $initialLeaveBalance) {
$user->setInitialLeaveBalance($initialLeaveBalance);
}
$this->entityManager->flush();
return json_encode(Serializer::userFull($user));
}
}
-394
View File
@@ -1,394 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskDocument;
use App\Entity\TaskEffort;
use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use Doctrine\Common\Collections\Collection;
/**
* Shared serialization helpers for MCP tools.
*
* Keeps JSON output consistent across all tools.
*/
final class Serializer
{
/**
* @return array{id: ?int, code: ?string, name: ?string}
*/
public static function projectRef(Project $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
];
}
/**
* @return array<string, mixed>
*/
public static function project(Project $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function status(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string, isFinal: bool}
*/
public static function statusFull(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'isFinal' => $status->getIsFinal(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function priority(?TaskPriority $priority): ?array
{
if (null === $priority) {
return null;
}
return [
'id' => $priority->getId(),
'label' => $priority->getLabel(),
'color' => $priority->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string}
*/
public static function effort(?TaskEffort $effort): ?array
{
if (null === $effort) {
return null;
}
return [
'id' => $effort->getId(),
'label' => $effort->getLabel(),
];
}
/**
* @return null|array{id: ?int, username: ?string}
*/
public static function user(?User $user): ?array
{
if (null === $user) {
return null;
}
return [
'id' => $user->getId(),
'username' => $user->getUsername(),
];
}
/**
* @param Collection<int, User> $users
*
* @return list<array{id: ?int, username: ?string}>
*/
public static function users(Collection $users): array
{
return $users->map(fn (User $u) => [
'id' => $u->getId(),
'username' => $u->getUsername(),
])->toArray();
}
/**
* @return null|array{id: ?int, title: ?string, color: ?string}
*/
public static function group(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'color' => $group->getColor(),
];
}
/**
* @return null|array{id: ?int, title: ?string}
*/
public static function groupRef(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
];
}
/**
* Full group serialization for MCP group tools (includes description, project, archived).
*
* @return array<string, mixed>
*/
public static function groupFull(TaskGroup $group): array
{
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => self::projectRef($group->getProject()),
'archived' => $group->isArchived(),
];
}
/**
* @param Collection<int, TaskTag> $tags
*
* @return list<array{id: ?int, label: ?string}>
*/
public static function tags(Collection $tags): array
{
return $tags->map(fn (TaskTag $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray();
}
/**
* @param Collection<int, TaskTag> $tags
*
* @return list<array{id: ?int, label: ?string, color: ?string}>
*/
public static function tagsWithColor(Collection $tags): array
{
return $tags->map(fn (TaskTag $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
])->toArray();
}
/**
* Compute duration in minutes between two timestamps, or null if still active.
*/
public static function durationMinutes(TimeEntry $entry): ?int
{
$started = $entry->getStartedAt();
$stopped = $entry->getStoppedAt();
if (null === $stopped || null === $started) {
return null;
}
return (int) round(($stopped->getTimestamp() - $started->getTimestamp()) / 60);
}
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function taskRef(?Task $task): ?array
{
if (null === $task) {
return null;
}
return [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
];
}
/**
* @return array<string, mixed>
*/
public static function timeEntry(TimeEntry $entry): array
{
return [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()),
];
}
/**
* @param Collection<int, TaskDocument> $documents
*
* @return list<array<string, mixed>>
*/
public static function documents(Collection $documents): array
{
return $documents->map(fn (TaskDocument $doc) => [
'id' => $doc->getId(),
'originalName' => $doc->getOriginalName(),
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()?->format('c'),
'uploadedBy' => self::user($doc->getUploadedBy()),
])->toArray();
}
/**
* @return array<string, mixed>
*/
public static function absenceRequest(AbsenceRequest $r): array
{
return [
'id' => $r->getId(),
'user' => self::user($r->getUser()),
'type' => $r->getType()?->value,
'typeLabel' => $r->getType()?->label(),
'startDate' => $r->getStartDate()?->format('Y-m-d'),
'endDate' => $r->getEndDate()?->format('Y-m-d'),
'startHalfDay' => $r->getStartHalfDay()?->value,
'endHalfDay' => $r->getEndHalfDay()?->value,
'countedDays' => $r->getCountedDays(),
'reason' => $r->getReason(),
'status' => $r->getStatus()->value,
'statusLabel' => $r->getStatus()->label(),
'rejectionReason' => $r->getRejectionReason(),
'justificationFileName' => $r->getJustificationFileName(),
'createdAt' => $r->getCreatedAt()?->format('c'),
'reviewedAt' => $r->getReviewedAt()?->format('c'),
'reviewedBy' => self::user($r->getReviewedBy()),
];
}
/**
* @return array<string, mixed>
*/
public static function absencePolicy(AbsencePolicy $p): array
{
return [
'id' => $p->getId(),
'type' => $p->getType()->value,
'typeLabel' => $p->getType()->label(),
'daysPerYear' => $p->getDaysPerYear(),
'daysPerEvent' => $p->getDaysPerEvent(),
'justificationRequired' => $p->isJustificationRequired(),
'noticeDays' => $p->getNoticeDays(),
'countWorkingDaysOnly' => $p->isCountWorkingDaysOnly(),
'active' => $p->isActive(),
];
}
/**
* @return array<string, mixed>
*/
public static function absenceBalance(AbsenceBalance $b): array
{
return [
'id' => $b->getId(),
'user' => self::user($b->getUser()),
'type' => $b->getType()->value,
'typeLabel' => $b->getType()->label(),
'period' => $b->getPeriod(),
'acquired' => $b->getAcquired(),
'acquiring' => $b->getAcquiring(),
'taken' => $b->getTaken(),
'pending' => $b->getPending(),
'acquiredTotal' => $b->getAcquiredTotal(),
'available' => $b->getAvailable(),
];
}
/**
* @return array<string, mixed>
*/
public static function client(Client $c): array
{
return [
'id' => $c->getId(),
'name' => $c->getName(),
'email' => $c->getEmail(),
'phone' => $c->getPhone(),
'street' => $c->getStreet(),
'city' => $c->getCity(),
'postalCode' => $c->getPostalCode(),
];
}
/**
* @return array<string, mixed>
*/
public static function userFull(User $u): array
{
return [
'id' => $u->getId(),
'username' => $u->getUsername(),
'roles' => $u->getRoles(),
'isEmployee' => $u->getIsEmployee(),
'hireDate' => $u->getHireDate()?->format('Y-m-d'),
'endDate' => $u->getEndDate()?->format('Y-m-d'),
'contractType' => $u->getContractType()?->value,
'workTimeRatio' => $u->getWorkTimeRatio(),
'annualLeaveDays' => $u->getAnnualLeaveDays(),
'referencePeriodStart' => $u->getReferencePeriodStart(),
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
];
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use App\Repository\TaskRepository;
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 TaskRepository $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->find($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(),
]);
}
}
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskRecurrence;
use App\Enum\RecurrenceType;
use App\Repository\TaskRepository;
use App\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 TaskRepository $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->find($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(),
]);
}
}
-178
View File
@@ -1,178 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\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 ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $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->find($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->find($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->find($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->find($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->find($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->find($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(),
]);
}
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\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,
]);
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRecurrenceRepository;
use App\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 TaskRecurrenceRepository $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->find($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),
]);
}
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Repository\TaskRepository;
use App\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 TaskRepository $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->find($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),
]);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
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 TaskRepository $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->find($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(),
]);
}
}
-100
View File
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
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 TaskRepository $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)));
}
}
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\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(),
]);
}
}
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Enum\RecurrenceType;
use App\Repository\TaskRecurrenceRepository;
use App\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 TaskRecurrenceRepository $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->find($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(),
]);
}
}
-182
View File
@@ -1,182 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskEffortRepository;
use App\Repository\TaskGroupRepository;
use App\Repository\TaskPriorityRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskStatusRepository;
use App\Repository\TaskTagRepository;
use App\Repository\UserRepository;
use App\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 TaskRepository $taskRepository,
private readonly TaskStatusRepository $taskStatusRepository,
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly TaskEffortRepository $taskEffortRepository,
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $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->find($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->find($statusId);
if (null === $status) {
throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId));
}
$task->setStatus($status);
}
if (null !== $priorityId) {
$priority = $this->taskPriorityRepository->find($priorityId);
if (null === $priority) {
throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId));
}
$task->setPriority($priority);
}
if (null !== $effortId) {
$effort = $this->taskEffortRepository->find($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->find($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->find($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(),
]);
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\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()]);
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskGroup;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
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 ProjectRepository $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->find($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));
}
}
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\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()]);
}
}
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\TaskStatus;
use App\Enum\StatusCategory;
use App\Repository\WorkflowRepository;
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 WorkflowRepository $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->find($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(),
]);
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\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()]);
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
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 TaskEffortRepository $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->find($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)]);
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskGroupRepository;
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 TaskGroupRepository $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->find($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)]);
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
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 TaskPriorityRepository $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->find($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)]);
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
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 TaskStatusRepository $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->find($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)]);
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
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 TaskTagRepository $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->find($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)]);
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
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 TaskEffortRepository $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));
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository;
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 TaskGroupRepository $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));
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
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 TaskPriorityRepository $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));
}
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\Project;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-statuses',
description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).',
)]
class ListStatusesTool
{
public function __construct(
private readonly TaskStatusRepository $taskStatusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(?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));
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
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 TaskTagRepository $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));
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
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 TaskEffortRepository $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->find($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()]);
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository;
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 TaskGroupRepository $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->find($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));
}
}
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
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 TaskPriorityRepository $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->find($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()]);
}
}
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository;
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 TaskStatusRepository $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->find($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(),
]);
}
}
-47
View File
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
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 TaskTagRepository $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->find($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()]);
}
}
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use App\Repository\UserRepository;
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-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
class CreateTimeEntryTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
*/
public function __invoke(
int $userId,
string $startedAt,
?string $title = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = $this->userRepository->find($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
// Check for existing active timer if creating a new active one
if (null === $stoppedAt) {
$activeEntry = $this->timeEntryRepository->findActiveByUser($user);
if (null !== $activeEntry) {
throw new InvalidArgumentException(sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId()));
}
}
$entry = new TimeEntry();
$entry->setUser($user);
$entry->setStartedAt(new DateTimeImmutable($startedAt));
if (null !== $title) {
$entry->setTitle($title);
}
if (null !== $stoppedAt) {
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
}
if (null !== $description) {
$entry->setDescription($description);
}
if (null !== $projectId) {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$entry->setProject($project);
}
if (null !== $taskId) {
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$entry->setTask($task);
}
if (null !== $tagIds) {
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$entry->addTag($tag);
}
}
$this->entityManager->persist($entry);
$this->entityManager->flush();
return json_encode(Serializer::timeEntry($entry));
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Repository\TimeEntryRepository;
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-time-entry', description: 'Delete a time entry permanently')]
class DeleteTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
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.');
}
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
}
$this->entityManager->remove($entry);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => 'Time entry deleted.',
]);
}
}
@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly Security $security,
) {}
public function __invoke(
?int $userId = null,
?int $projectId = null,
?int $taskId = null,
?string $startDate = null,
?string $endDate = null,
int $limit = 100,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$limit = min($limit, 200);
$qb = $this->timeEntryRepository->createQueryBuilder('te')
->leftJoin('te.user', 'u')->addSelect('u')
->leftJoin('te.project', 'p')->addSelect('p')
->leftJoin('te.task', 't')->addSelect('t')
->leftJoin('te.tags', 'tg')->addSelect('tg')
->orderBy('te.startedAt', 'DESC')
->setMaxResults($limit)
;
if (null !== $userId) {
$qb->andWhere('u.id = :userId')->setParameter('userId', $userId);
}
if (null !== $projectId) {
$qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId);
}
if (null !== $taskId) {
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
}
if (null !== $startDate) {
$qb->andWhere('te.startedAt >= :startDate')
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
;
}
if (null !== $endDate) {
$qb->andWhere('te.startedAt <= :endDate')
->setParameter('endDate', new DateTimeImmutable($endDate.' 23:59:59'))
;
}
$entries = $qb->getQuery()->getResult();
return json_encode(array_map(Serializer::timeEntry(...), $entries));
}
}
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
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-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
class UpdateTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
*/
public function __invoke(
int $id,
?string $title = null,
?string $startedAt = null,
?string $stoppedAt = null,
?int $projectId = null,
?int $taskId = null,
?array $tagIds = null,
?string $description = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
}
if (null !== $title) {
$entry->setTitle($title);
}
if (null !== $startedAt) {
$entry->setStartedAt(new DateTimeImmutable($startedAt));
}
if (null !== $stoppedAt) {
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
}
if (null !== $description) {
$entry->setDescription($description);
}
if (null !== $projectId) {
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
}
$entry->setProject($project);
}
if (null !== $taskId) {
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
$entry->setTask($task);
}
if (null !== $tagIds) {
foreach ($entry->getTags()->toArray() as $existingTag) {
$entry->removeTag($existingTag);
}
foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId);
if (null === $tag) {
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
}
$entry->addTag($tag);
}
}
$this->entityManager->flush();
return json_encode(Serializer::timeEntry($entry));
}
}
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use App\Repository\WorkflowRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-workflows',
description: 'List all workflows (status templates) with their statuses grouped under each workflow. Each project has one workflow that defines its kanban columns.',
)]
class ListWorkflowsTool
{
public function __construct(
private readonly WorkflowRepository $workflowRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($w) => [
'id' => $w->getId(),
'name' => $w->getName(),
'isDefault' => $w->isDefault(),
'position' => $w->getPosition(),
'statuses' => array_map(fn ($s) => [
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
'category' => $s->getCategory()->value,
], $w->getStatuses()->toArray()),
], $workflows));
}
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use ApiPlatform\Metadata\Post;
use App\Entity\Project;
use App\State\SwitchProjectWorkflowProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Throwable;
#[McpTool(
name: 'switch-project-workflow',
description: 'Switch a project to another workflow. mapping must cover every status currently used by the project\'s tasks: keys are source status IDs (string), values are target status IDs in the new workflow (int) or null to send tasks to backlog. Requires ROLE_ADMIN. Returns { migratedTaskCount }.',
)]
class SwitchProjectWorkflowTool
{
public function __construct(
private readonly SwitchProjectWorkflowProcessor $processor,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param array<string, null|int> $mapping
*/
public function __invoke(int $projectId, int $workflowId, array $mapping): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$project = $this->entityManager->find(Project::class, $projectId);
if (!$project) {
return json_encode(['error' => 'Project not found.']);
}
$fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([
'workflowId' => $workflowId,
'mapping' => $mapping,
]));
try {
$result = $this->processor->process(
$project,
operation: new Post(name: 'switch_workflow'),
uriVariables: ['id' => $projectId],
context: ['request' => $fakeRequest],
);
} catch (Throwable $e) {
return json_encode(['error' => $e->getMessage()]);
}
return json_encode([
'migratedTaskCount' => $result->migratedTaskCount,
'projectId' => $result->projectId,
'workflowId' => $result->workflowId,
]);
}
}