feat(catalog) : implement CategoryProvider and CategoryProcessor with soft delete and 409 mapping

- CategoryProvider applique le filtre soft-delete par defaut (RG-1.08), respecte ?includeDeleted=true (RG-1.09), trie par name ASC (RG-1.10) et renvoie 404 sur Get d'une soft-deleted hors flag (RG-1.11). Cable aussi sur Patch+Delete pour fermer la fuite sur PATCH.
- CategoryProcessor : trim du name au POST/PATCH (RG-1.03), conversion DELETE en UPDATE avec deletedAt=now() via persist_processor (RG-1.12), mapping UniqueConstraintViolationException -> HTTP 409 avec le message attendu (RG-1.07).
- Cablage des Provider/Processor dans #[ApiResource] de Category.
- DoctrineCategoryRepository expose createListQueryBuilder($includeDeleted) pour le Provider.
This commit is contained in:
Matthieu
2026-05-28 09:03:26 +02:00
parent e5fa6cb3c8
commit 9569251900
5 changed files with 193 additions and 3 deletions
+12 -3
View File
@@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
@@ -33,32 +35,39 @@ use Symfony\Component\Validator\Constraints as Assert;
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
* audit_log par l'AuditListener du module Core.
*
* Les Provider (filtre soft-delete) et Processor (trim, soft delete, 409)
* seront branches au ticket 0.3 (ERP-45). Au ticket 0.2, les operations
* utilisent les state Doctrine par defaut d'API Platform.
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
* au ticket 0.3 (ERP-45).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Post(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Patch(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
new Delete(
security: "is_granted('catalog.categories.manage')",
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
],
)]
@@ -5,10 +5,18 @@ declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Category;
use Doctrine\ORM\QueryBuilder;
interface CategoryRepositoryInterface
{
public function findById(int $id): ?Category;
public function save(Category $category): void;
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - Tri : name ASC (RG-1.10).
*/
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Processor Category : applique les regles de gestion en ecriture.
*
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
* (collision sur l'index partiel uq_category_name_type_active) est traduite
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
*
* @implements ProcessorInterface<Category, null|Category>
*/
final class CategoryProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Category) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
if ($operation instanceof DeleteOperationInterface) {
$data->setDeletedAt(new DateTimeImmutable());
try {
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Par construction, le soft delete ne peut pas violer l'index
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
// On laisse remonter en 500 pour signaler une anomalie reelle.
throw $e;
}
return null;
}
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
if (null !== $data->getName()) {
$data->setName(trim($data->getName()));
}
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
throw new HttpException(
409,
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e,
);
}
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
* sans le flag (RG-1.11, via retour null).
*
* Choix d'implementation : QueryBuilder via le repository custom plutot
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
* Avantage : pas de magie globale, lisibilite directe du code, controle
* fin du flag includeDeleted par requete.
*
* @implements ProviderInterface<Category>
*/
final class CategoryProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
private readonly CategoryRepositoryInterface $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
{
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
return $this->repository
->createListQueryBuilder($includeDeleted)
->getQuery()
->getResult()
;
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$category = $this->repository->findById((int) $id);
if (null === $category) {
return null;
}
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
if (!$includeDeleted && null !== $category->getDeletedAt()) {
return null;
}
return $category;
}
/**
* Lit le flag includeDeleted depuis les filtres API Platform.
* Accepte "true" / "1" / true (booleen).
*/
private function readIncludeDeleted(array $context): bool
{
$raw = $context['filters']['includeDeleted'] ?? false;
if (is_bool($raw)) {
return $raw;
}
if (is_string($raw)) {
return in_array(strtolower($raw), ['true', '1'], true);
}
return false;
}
}
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -29,4 +30,17 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$this->getEntityManager()->persist($category);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
->orderBy('c.name', 'ASC')
;
if (!$includeDeleted) {
$qb->andWhere('c.deletedAt IS NULL');
}
return $qb;
}
}