feat(directory) : migrate Client into Directory module (back)

LST-58 (2.4), part 1/2 — Client move. Prospect + repertoire front are pending
the product spec and will be added on this branch afterward.

- Client entity moved to src/Module/Directory/Domain/Entity; repository split
  into Domain/Repository/ClientRepositoryInterface + Doctrine impl (bound in
  services.yaml). 5 client MCP tools moved to Infrastructure/Mcp/Tool, now
  injecting the interface.
- resolve_target_entities ClientInterface repointed to Directory\Client;
  Directory mapping added; DirectoryModule registered (id directory, 2 RBAC
  perms). Client.projects relation now uses ProjectInterface -> Directory no
  longer depends on ProjectManagement.
- ProjectManagement Create/UpdateProjectTool inject Directory's
  ClientRepositoryInterface; Serializer and fixtures repointed.
- Garde-fous: #[Auditable] + Timestampable/Blamable on Client (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT).

161 tests green, mapping valid, no API route regression, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 18:51:49 +02:00
parent 163bf0891a
commit c5738d269b
18 changed files with 190 additions and 51 deletions
@@ -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,47 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Directory\Domain\Entity\Client;
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));
}
}
@@ -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,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
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,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,67 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
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: '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 $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->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);
}
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));
}
}