Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
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:
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user