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
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory;
use App\Shared\Domain\Module\ModuleInterface;
final class DirectoryModule implements ModuleInterface
{
public static function id(): string
{
return 'directory';
}
public static function label(): string
{
return 'Répertoire';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Directory.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'directory.clients.view', 'label' => 'Voir les clients'],
['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'],
];
}
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['client:read']],
denormalizationContext: ['groups' => ['client:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
class Client implements ClientInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client:read', 'project:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['client:read', 'client:write', 'project:read', 'user:list'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $city = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $postalCode = null;
/** @var Collection<int, ProjectInterface> */
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
private Collection $projects;
public function __construct()
{
$this->projects = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
/** @return Collection<int, ProjectInterface> */
public function getProjects(): Collection
{
return $this->projects;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Client;
interface ClientRepositoryInterface
{
public function findById(int $id): ?Client;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Client[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -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));
}
}
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -20,7 +20,7 @@ class CreateProjectTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository,
private readonly ClientRepositoryInterface $clientRepository,
private readonly Security $security,
) {}
@@ -46,7 +46,7 @@ class CreateProjectTool
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface;
use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -20,7 +20,7 @@ class UpdateProjectTool
{
public function __construct(
private readonly ProjectRepositoryInterface $projectRepository,
private readonly ClientRepository $clientRepository,
private readonly ClientRepositoryInterface $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -57,7 +57,7 @@ class UpdateProjectTool
$project->setColor($color);
}
if (null !== $clientId) {
$client = $this->clientRepository->find($clientId);
$client = $this->clientRepository->findById($clientId);
if (null === $client) {
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
}