feat(directory) : add Prospect entity with conversion to Client (back)
LST-58 (2.4), part 2 — Prospect (new entity). Completes the Directory backend.
- ProspectStatus enum (new/contacted/qualified/won/lost) + Prospect entity
(name, company, email, phone, address, status, source, notes,
convertedClient -> ClientInterface) with Timestampable/Blamable + #[Auditable].
- API: GetCollection/Get (ROLE_USER), Post/Patch/Delete (ROLE_ADMIN),
custom POST /prospects/{id}/convert (ConvertProspectProcessor: creates a
Client from the prospect, links convertedClient, sets status=Won; idempotent).
SearchFilter on status.
- Repository interface + Doctrine impl (bound); 6 MCP tools (list/get/create/
update/delete/convert-prospect); Serializer::prospect(). Module perms
directory.prospects.view/manage. Demo fixtures (3 prospects, one converted).
- Additive migration: CREATE TABLE prospect + FKs ON DELETE SET NULL + COMMENT.
163 tests green (incl. conversion test), mapping valid, cs-fixer clean.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<?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\Client;
|
||||
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.
|
||||
*
|
||||
* Loads the Prospect via the URI id, creates a Client (name = company or name,
|
||||
* copying contact details), links it back via convertedClient and flags the
|
||||
* prospect as Won. 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());
|
||||
$client->setStreet($prospect->getStreet());
|
||||
$client->setCity($prospect->getCity());
|
||||
$client->setPostalCode($prospect->getPostalCode());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
$prospect->setConvertedClient($client);
|
||||
$prospect->setStatus(ProspectStatus::Won);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $prospect;
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
<?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 App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
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: '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());
|
||||
$client->setStreet($prospect->getStreet());
|
||||
$client->setCity($prospect->getCity());
|
||||
$client->setPostalCode($prospect->getPostalCode());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
$prospect->setConvertedClient($client);
|
||||
$prospect->setStatus(ProspectStatus::Won);
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Entity\Prospect;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
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 $street = null,
|
||||
?string $city = null,
|
||||
?string $postalCode = 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->setStreet($street);
|
||||
$prospect->setCity($city);
|
||||
$prospect->setPostalCode($postalCode);
|
||||
$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\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\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
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,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
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,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Directory\Domain\Enum\ProspectStatus;
|
||||
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: '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 $street = null,
|
||||
?string $city = null,
|
||||
?string $postalCode = 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 !== $street) {
|
||||
$prospect->setStreet($street);
|
||||
}
|
||||
if (null !== $city) {
|
||||
$prospect->setCity($city);
|
||||
}
|
||||
if (null !== $postalCode) {
|
||||
$prospect->setPostalCode($postalCode);
|
||||
}
|
||||
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