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:
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Directory\Domain\Entity\Address;
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Entity\Contact;
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Converts a Prospect into a Client and reassigns its contacts, addresses and
|
||||
* commercial reports to the new client (preserving the commercial history).
|
||||
*
|
||||
* Idempotent: if already converted, returns it unchanged.
|
||||
*
|
||||
* @implements ProcessorInterface<Prospect, Prospect>
|
||||
*/
|
||||
final readonly class ConvertProspectProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private ProspectRepositoryInterface $prospectRepository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Prospect
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
$prospect = is_numeric($id) ? $this->prospectRepository->findById((int) $id) : null;
|
||||
|
||||
if (!$prospect instanceof Prospect) {
|
||||
throw new NotFoundHttpException('Prospect not found.');
|
||||
}
|
||||
|
||||
// Idempotent: already converted, return as-is.
|
||||
if (null !== $prospect->getConvertedClient()) {
|
||||
return $prospect;
|
||||
}
|
||||
|
||||
$client = new Client();
|
||||
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
|
||||
$client->setEmail($prospect->getEmail());
|
||||
$client->setPhone($prospect->getPhone());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
$this->reassignContacts($prospect, $client);
|
||||
$this->reassignAddresses($prospect, $client);
|
||||
$this->reassignReports($prospect, $client);
|
||||
|
||||
$prospect->setConvertedClient($client);
|
||||
$prospect->setStatus(ProspectStatus::Won);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $prospect;
|
||||
}
|
||||
|
||||
private function reassignContacts(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(Contact::class)->findBy(['prospect' => $prospect]) as $contact) {
|
||||
$contact->setClient($client);
|
||||
$contact->setProspect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private function reassignAddresses(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(Address::class)->findBy(['prospect' => $prospect]) as $address) {
|
||||
$address->setClient($client);
|
||||
$address->setProspect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private function reassignReports(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(CommercialReport::class)->findBy(['prospect' => $prospect]) as $report) {
|
||||
$report->setClient($client);
|
||||
$report->setProspect(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Throwable;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ReportDocument, ReportDocument>
|
||||
*/
|
||||
final readonly class ReportDocumentProcessor implements ProcessorInterface
|
||||
{
|
||||
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain', 'text/csv',
|
||||
'application/zip', 'application/x-rar-compressed', 'application/gzip',
|
||||
'application/json', 'application/xml', 'text/xml',
|
||||
];
|
||||
|
||||
private const MIME_TO_EXTENSION = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/vnd.ms-powerpoint' => 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
||||
'text/plain' => 'txt',
|
||||
'text/csv' => 'csv',
|
||||
'application/zip' => 'zip',
|
||||
'application/x-rar-compressed' => 'rar',
|
||||
'application/gzip' => 'gz',
|
||||
'application/json' => 'json',
|
||||
'application/xml' => 'xml',
|
||||
'text/xml' => 'xml',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private string $uploadDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param ReportDocument $data
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('Creating report documents requires admin privileges.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if (null === $request) {
|
||||
throw new BadRequestHttpException('No request available.');
|
||||
}
|
||||
|
||||
$document = $this->createUpload($request);
|
||||
$document->setCreatedAt(new DateTimeImmutable());
|
||||
$document->setUploadedBy($this->security->getUser());
|
||||
|
||||
try {
|
||||
$this->entityManager->persist($document);
|
||||
$this->entityManager->flush();
|
||||
} catch (Throwable $e) {
|
||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
@unlink($filePath);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function createUpload(Request $request): ReportDocument
|
||||
{
|
||||
$file = $request->files->get('file');
|
||||
|
||||
if (null === $file || !$file->isValid()) {
|
||||
throw new BadRequestHttpException('No valid file uploaded.');
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
|
||||
}
|
||||
|
||||
$report = $this->resolveReport((string) $request->request->get('commercialReport', ''));
|
||||
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
|
||||
}
|
||||
|
||||
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
|
||||
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
|
||||
|
||||
if (!is_dir($this->uploadDir)) {
|
||||
mkdir($this->uploadDir, 0o775, true);
|
||||
}
|
||||
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$document = new ReportDocument();
|
||||
$document->setCommercialReport($report);
|
||||
$document->setOriginalName($originalName);
|
||||
$document->setFileName($fileName);
|
||||
$document->setMimeType($mimeType);
|
||||
$document->setSize($fileSize);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function resolveReport(string $iri): CommercialReport
|
||||
{
|
||||
$idString = basename($iri);
|
||||
|
||||
if ('' === $iri || !ctype_digit($idString)) {
|
||||
throw new BadRequestHttpException('A valid commercialReport IRI is required.');
|
||||
}
|
||||
|
||||
$report = $this->entityManager->getRepository(CommercialReport::class)->find((int) $idString);
|
||||
|
||||
if (null === $report) {
|
||||
throw new BadRequestHttpException('Commercial report not found.');
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class ReportDocumentDownloadController
|
||||
{
|
||||
private const INLINE_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ReportDocumentRepositoryInterface $repository,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(int $id): Response
|
||||
{
|
||||
$document = $this->repository->findById($id);
|
||||
|
||||
if (null === $document || null === $document->getFileName()) {
|
||||
throw new NotFoundHttpException('Document not found.');
|
||||
}
|
||||
|
||||
$filePath = $this->uploadDir.'/'.$document->getFileName();
|
||||
|
||||
if (!is_file($filePath)) {
|
||||
throw new NotFoundHttpException('File missing on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = (string) $document->getMimeType();
|
||||
|
||||
$disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true)
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response->setContentDisposition($disposition, (string) $document->getOriginalName());
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Address;
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Address>
|
||||
*/
|
||||
final class DoctrineAddressRepository extends ServiceEntityRepository implements AddressRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Address::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Address
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Client>
|
||||
*/
|
||||
final class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Client::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Client
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CommercialReport>
|
||||
*/
|
||||
final class DoctrineCommercialReportRepository extends ServiceEntityRepository implements CommercialReportRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CommercialReport::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?CommercialReport
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Contact;
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Contact>
|
||||
*/
|
||||
final class DoctrineContactRepository extends ServiceEntityRepository implements ContactRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Contact::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Contact
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Prospect>
|
||||
*/
|
||||
final class DoctrineProspectRepository extends ServiceEntityRepository implements ProspectRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Prospect::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Prospect
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ReportDocument>
|
||||
*/
|
||||
final class DoctrineReportDocumentRepository extends ServiceEntityRepository implements ReportDocumentRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ReportDocument::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ReportDocument
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\EventListener;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class CommercialReportAuthorListener
|
||||
{
|
||||
public function __construct(private Security $security) {}
|
||||
|
||||
public function prePersist(CommercialReport $report, PrePersistEventArgs $args): void
|
||||
{
|
||||
if (null !== $report->getAuthor()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if ($user instanceof UserInterface) {
|
||||
$report->setAuthor($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\EventListener;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final readonly class ReportDocumentListener
|
||||
{
|
||||
public function __construct(
|
||||
private string $uploadDir,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function preRemove(ReportDocument $document, PreRemoveEventArgs $args): void
|
||||
{
|
||||
$fileName = $document->getFileName();
|
||||
|
||||
if (null === $fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->uploadDir.'/'.$fileName;
|
||||
|
||||
if (is_file($path) && !@unlink($path)) {
|
||||
$this->logger->warning('Failed to delete report document file', ['path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Address;
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Entity\Contact;
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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: 'convert-prospect', description: 'Convert a prospect into a client (admin). Idempotent: returns the prospect unchanged if already converted.')]
|
||||
class ConvertProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
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.');
|
||||
}
|
||||
|
||||
$prospect = $this->prospectRepository->findById($id);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null === $prospect->getConvertedClient()) {
|
||||
$client = new Client();
|
||||
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
|
||||
$client->setEmail($prospect->getEmail());
|
||||
$client->setPhone($prospect->getPhone());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
$this->reassignContacts($prospect, $client);
|
||||
$this->reassignAddresses($prospect, $client);
|
||||
$this->reassignReports($prospect, $client);
|
||||
|
||||
$prospect->setConvertedClient($client);
|
||||
$prospect->setStatus(ProspectStatus::Won);
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
|
||||
private function reassignContacts(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(Contact::class)->findBy(['prospect' => $prospect]) as $contact) {
|
||||
$contact->setClient($client);
|
||||
$contact->setProspect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private function reassignAddresses(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(Address::class)->findBy(['prospect' => $prospect]) as $address) {
|
||||
$address->setClient($client);
|
||||
$address->setProspect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private function reassignReports(Prospect $prospect, Client $client): void
|
||||
{
|
||||
foreach ($this->entityManager->getRepository(CommercialReport::class)->findBy(['prospect' => $prospect]) as $report) {
|
||||
$report->setClient($client);
|
||||
$report->setProspect(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Client;
|
||||
use App\Shared\Infrastructure\Mcp\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 {
|
||||
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);
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::client($client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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-prospect', description: 'Create a prospect (admin). Only name is required. Status defaults to "new".')]
|
||||
class CreateProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
?string $company = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $status = null,
|
||||
?string $source = null,
|
||||
?string $notes = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$prospect = new Prospect();
|
||||
$prospect->setName($name);
|
||||
$prospect->setCompany($company);
|
||||
$prospect->setEmail($email);
|
||||
$prospect->setPhone($phone);
|
||||
$prospect->setSource($source);
|
||||
$prospect->setNotes($notes);
|
||||
|
||||
if (null !== $status) {
|
||||
$statusEnum = ProspectStatus::tryFrom($status);
|
||||
if (null === $statusEnum) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status));
|
||||
}
|
||||
$prospect->setStatus($statusEnum);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($prospect);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
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 ClientRepositoryInterface $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->findById($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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
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-prospect', description: 'Delete a prospect (admin).')]
|
||||
class DeleteProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
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.');
|
||||
}
|
||||
|
||||
$prospect = $this->prospectRepository->findById($id);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$name = $prospect->getName();
|
||||
$this->entityManager->remove($prospect);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $name)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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 ClientRepositoryInterface $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->findById($id);
|
||||
if (null === $client) {
|
||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::client($client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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-prospect', description: 'Get a prospect by ID with full details.')]
|
||||
class GetProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$prospect = $this->prospectRepository->findById($id);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
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 ClientRepositoryInterface $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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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-prospects', description: 'List prospects, optionally filtered by status (new, contacted, qualified, won, lost).')]
|
||||
class ListProspectsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(?string $status = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $status) {
|
||||
$statusEnum = ProspectStatus::tryFrom($status);
|
||||
if (null === $statusEnum) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status));
|
||||
}
|
||||
$criteria['status'] = $statusEnum;
|
||||
}
|
||||
|
||||
$prospects = $this->prospectRepository->findBy($criteria, ['name' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(static fn ($prospect) => Serializer::prospect($prospect), $prospects));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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 ClientRepositoryInterface $clientRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $name = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$client = $this->clientRepository->findById($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);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::client($client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
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-prospect', description: 'Update a prospect (admin). Only provided fields change.')]
|
||||
class UpdateProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $name = null,
|
||||
?string $company = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $status = null,
|
||||
?string $source = null,
|
||||
?string $notes = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$prospect = $this->prospectRepository->findById($id);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$prospect->setName($name);
|
||||
}
|
||||
if (null !== $company) {
|
||||
$prospect->setCompany($company);
|
||||
}
|
||||
if (null !== $email) {
|
||||
$prospect->setEmail($email);
|
||||
}
|
||||
if (null !== $phone) {
|
||||
$prospect->setPhone($phone);
|
||||
}
|
||||
if (null !== $status) {
|
||||
$statusEnum = ProspectStatus::tryFrom($status);
|
||||
if (null === $statusEnum) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid status "%s". Allowed: new, contacted, qualified, won, lost.', $status));
|
||||
}
|
||||
$prospect->setStatus($statusEnum);
|
||||
}
|
||||
if (null !== $source) {
|
||||
$prospect->setSource($source);
|
||||
}
|
||||
if (null !== $notes) {
|
||||
$prospect->setNotes($notes);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user