From 433b6c2d71aa0c07250adcccc0a9bf003dd1d1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 24 Sep 2025 21:06:30 +0200 Subject: [PATCH] Add MotiveNormalizer and support for parent/child relationships in motives - Introduced `MotiveNormalizer` to handle normalization of `Motive` entities. - Enhanced serialization groups to support parent-to-children and children-to-parent relationships. - Updated TypeScript types and entities to align with the new schema. - Adjusted API responses and query behavior to reflect hierarchical motive relationships. --- .../src/Controller/MotiveApiController.php | 5 +- .../ChillTicketBundle/src/Entity/Motive.php | 26 +-- .../src/Entity/MotiveHistory.php | 2 +- .../src/Resources/public/types.ts | 24 ++- .../Normalizer/MotiveNormalizer.php | 103 ++++++++++++ .../Normalizer/TicketNormalizer.php | 2 +- .../Normalizer/MotiveNormalizerTest.php | 154 ++++++++++++++++++ 7 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php diff --git a/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php index d57b43e7b..1b9ce263c 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php @@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Controller; use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer; use Doctrine\ORM\QueryBuilder; use Symfony\Component\HttpFoundation\Request; @@ -21,13 +22,13 @@ final class MotiveApiController extends ApiController protected function customizeQuery(string $action, Request $request, $query): void { /* @var $query QueryBuilder */ - $query->andWhere('e.active = TRUE'); + $query->andWhere('e.active = TRUE')->andWhere('e.parent IS NULL'); } protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array { return match ($request->getMethod()) { - Request::METHOD_GET => ['groups' => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]], + Request::METHOD_GET => ['groups' => ['read', 'read:extended', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY, MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]], default => parent::getContextForSerialization($action, $request, $_format, $entity), }; } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php index 2852bdc22..0ed2f3d5d 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -15,30 +15,25 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ReadableCollection; +use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity()] #[ORM\Table(name: 'motive', schema: 'chill_ticket')] -#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive' => Motive::class])] class Motive { #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] - #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] - #[Serializer\Groups(['read'])] private array $label = []; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] - #[Serializer\Groups(['read'])] private bool $active = true; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)] - #[Serializer\Groups(['read'])] private ?EmergencyStatusEnum $makeTicketEmergency = null; /** @@ -49,15 +44,17 @@ class Motive private Collection $storedObjects; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] - #[Serializer\Groups(['read'])] private array $supplementaryComments = []; #[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')] private ?Motive $parent = null; - #[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'motive')] - private Collection $children; + /** + * @var Collection&Selectable + */ + #[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'parent')] + private Collection&Selectable $children; public function __construct() { @@ -77,7 +74,6 @@ class Motive $this->storedObjects->removeElement($storedObject); } - #[Serializer\Groups(['read'])] public function getStoredObjects(): ReadableCollection { return $this->storedObjects; @@ -189,4 +185,14 @@ class Motive { $this->children->removeElement($child); } + + public function getChildren(): ReadableCollection&Selectable + { + return $this->children; + } + + public function getParent(): ?Motive + { + return $this->parent; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php index e2902aae3..e3de710be 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php @@ -42,7 +42,7 @@ class MotiveHistory implements TrackCreationInterface public function __construct( #[ORM\ManyToOne(targetEntity: Motive::class)] #[ORM\JoinColumn(nullable: false)] - #[Serializer\Groups(['read'])] + #[Serializer\Groups(['read', 'read:children-to-parent'])] private Motive $motive, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index 4c31f2341..bb078a105 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -8,14 +8,32 @@ import { Person } from "ChillPersonAssets/types"; import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types"; import { StoredObject } from "ChillDocStoreAssets/types"; -export interface Motive { +interface MotiveBase { type: "ticket_motive"; id: number; active: boolean; label: TranslatableString; +} + +/** + * Represent a motive with basic information and parent motive. + * + * Match the "read" and "read:children-to-parent" serializer groups. + */ +export interface MotiveWithParent extends MotiveBase { + parent: MotiveWithParent|null; +} + +/** + * Represents a motive for a ticket, including details like emergency status, stored objects, and supplementary comments. + * + * Match the "read:extended" serializer group in MotiveNormalizer. + */ +export interface Motive extends MotiveBase { makeTicketEmergency: TicketEmergencyState; storedObjects: StoredObject[]; supplementaryComments: { label: string }[]; + children: Motive[]; } export type TicketState = "open" | "closed" | "close"; @@ -45,7 +63,7 @@ export interface MotiveHistory { id: number; startDate: null; endDate: null | DateTime; - motive: Motive; + motive: MotiveWithParent; createdBy: User | null; createdAt: DateTime | null; } @@ -140,7 +158,7 @@ interface BaseTicket< createdAt: DateTime | null; currentAddressees: UserGroupOrUser[]; currentPersons: Person[]; - currentMotive: null | Motive; + currentMotive: null | MotiveWithParent; currentState: TicketState | null; emergency: TicketEmergencyState | null; caller: Person | Thirdparty | null; diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php new file mode 100644 index 000000000..a9b33483b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php @@ -0,0 +1,103 @@ + 'ticket_motive', + 'id' => $object->getId(), + 'label' => $object->getLabel(), + 'active' => $object->isActive(), + ]; + } + + if (in_array('read:extended', $groups, true)) { + $data['makeTicketEmergency'] = $object->getMakeTicketEmergency(); + $data['supplementaryComments'] = $object->getSupplementaryComments(); + // Normalize stored objects (delegated to their own normalizer when present) + $storedObjects = []; + foreach ($object->getStoredObjects() as $storedObject) { + $storedObjects[] = $this->normalizer->normalize($storedObject, $format, $context); + } + $data['storedObjects'] = $storedObjects; + + } + + if (in_array(self::GROUP_PARENT_TO_CHILDREN, $groups, true)) { + // Normalize children recursively (but we do not expose parent to avoid cycles) + $children = []; + foreach ($object->getChildren() as $child) { + $children[] = $this->normalizer->normalize($child, $format, $context); + } + $data['children'] = $children; + } elseif (in_array(self::GROUP_CHILDREN_TO_PARENT, $groups, true)) { + $data['parent'] = $this->normalizer->normalize($object->getParent(), $format, $context); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof Motive; + } + + /** + * Optimization hint for the Serializer (available since Symfony 5.3+). + * + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Motive::class => true, + ]; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index ef02ce5eb..16bafdc0c 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -51,7 +51,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ]), 'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']), 'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']), - 'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']), + 'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]), 'currentState' => $object->getState()?->value ?? 'open', 'emergency' => $object->getEmergencyStatus()?->value ?? 'no', 'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']), diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php new file mode 100644 index 000000000..ce9a8066f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php @@ -0,0 +1,154 @@ +setLabel(['fr' => 'Logement', 'en' => 'Housing']); + // active is true by default + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($motive, 'json', ['groups' => 'read']); + + self::assertSame('ticket_motive', $actual['type']); + self::assertNull($actual['id']); + self::assertSame(['fr' => 'Logement', 'en' => 'Housing'], $actual['label']); + self::assertTrue($actual['active']); + // no extended fields here + self::assertArrayNotHasKey('makeTicketEmergency', $actual); + self::assertArrayNotHasKey('supplementaryComments', $actual); + self::assertArrayNotHasKey('storedObjects', $actual); + self::assertArrayNotHasKey('children', $actual); + } + + public function testNormalizeExtended(): void + { + $motive = new Motive(); + $motive->setLabel(['fr' => 'Financier']); + $motive->setMakeTicketEmergency(EmergencyStatusEnum::YES); + $motive->addSupplementaryComment(['label' => 'Justifier le revenu']); + $motive->addStoredObject(new StoredObject('pending')); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($motive, 'json', ['groups' => ['read', 'read:extended']]); + + self::assertSame('ticket_motive', $actual['type']); + self::assertSame(['fr' => 'Financier'], $actual['label']); + self::assertSame(EmergencyStatusEnum::YES, $actual['makeTicketEmergency']); + self::assertSame([ + ['label' => 'Justifier le revenu'], + ], $actual['supplementaryComments']); + self::assertSame([ + ['stored_object'], + ], $actual['storedObjects']); + } + + public function testNormalizeParentToChildren(): void + { + $parent = new Motive(); + $parent->setLabel(['fr' => 'Parent']); + $child1 = new Motive(); + $child1->setLabel(['fr' => 'Enfant 1']); + $child2 = new Motive(); + $child2->setLabel(['fr' => 'Enfant 2']); + + // build relation + $child1->setParent($parent); + $child2->setParent($parent); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($parent, 'json', ['groups' => [MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]]); + + // children must be normalized by the injected normalizer and parent not exposed + self::assertArrayHasKey('children', $actual); + self::assertSame([ + ['motive' => 'normalized'], + ['motive' => 'normalized'], + ], $actual['children']); + self::assertArrayNotHasKey('parent', $actual); + } + + public function testNormalizeChildrenToParent(): void + { + $parent = new Motive(); + $parent->setLabel(['fr' => 'Parent']); + $child = new Motive(); + $child->setLabel(['fr' => 'Enfant']); + $child->setParent($parent); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($child, 'json', ['groups' => [MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]); + + // parent must be normalized by the injected normalizer and children not exposed + self::assertArrayHasKey('parent', $actual); + self::assertSame(['motive' => 'normalized'], $actual['parent']); + self::assertArrayNotHasKey('children', $actual); + } + + public function testSupportsAndSupportedTypes(): void + { + $motive = new Motive(); + $normalizer = new MotiveNormalizer(); + + self::assertTrue($normalizer->supportsNormalization($motive, 'json')); + self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json')); + + $supported = $normalizer->getSupportedTypes('json'); + self::assertArrayHasKey(Motive::class, $supported); + self::assertTrue($supported[Motive::class]); + } + + private function buildDummyNormalizer(): NormalizerInterface + { + return new class () implements NormalizerInterface { + public function normalize($object, ?string $format = null, array $context = []): array + { + if ($object instanceof StoredObject) { + return ['stored_object']; + } + if ($object instanceof Motive) { + return ['motive' => 'normalized']; + } + + return ['normalized']; + } + + public function supportsNormalization($data, ?string $format = null): bool + { + return true; + } + }; + } +}