From 56a1a488dea20143fde32a610e1c3627495df2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 18 Apr 2024 15:44:05 +0200 Subject: [PATCH 01/12] Return the content of the ticket on replace motive POST request --- .../src/Controller/ReplaceMotiveController.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php index d6f1acf94..8af2773e8 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php @@ -59,6 +59,11 @@ final readonly class ReplaceMotiveController $this->entityManager->flush(); - return new JsonResponse(null, Response::HTTP_CREATED); + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_CREATED, + [], + true + ); } } From 613ee8b186c871b9268ed1489b260f1f614eb1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 18 Apr 2024 21:57:55 +0200 Subject: [PATCH 02/12] Add functionality to add comments to tickets A new controller, 'AddCommentController', has been added. This controller implements the 'AddCommentCommandHandler', allowing users to add comments to tickets. Additionally, corresponding test cases were implemented. The Ticket entity was also updated to accept and manage comments. API endpoint specs were updated to reflect these changes. --- .../ChillTicketBundle/chill.api.specs.yaml | 29 +++++ .../src/Action/Ticket/AddCommentCommand.php | 25 +++++ .../Handler/AddCommentCommandHandler.php | 31 ++++++ .../src/Controller/AddCommentController.php | 68 ++++++++++++ .../ChillTicketBundle/src/Entity/Comment.php | 8 +- .../ChillTicketBundle/src/Entity/Ticket.php | 13 ++- .../Handler/AddCommentCommandHandlerTest.php | 52 +++++++++ .../Controller/AddCommentControllerTest.php | 104 ++++++++++++++++++ 8 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AddCommentCommandHandler.php create mode 100644 src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index ce53732ff..86049cc24 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -64,3 +64,32 @@ paths: description: "ACCEPTED" 422: description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/comment/add: + post: + tags: + - ticket + summary: Add a comment to an existing ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php new file mode 100644 index 000000000..1c009cfc8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php @@ -0,0 +1,25 @@ +content, $ticket); + + $this->entityManager->persist($comment); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php new file mode 100644 index 000000000..157e69698 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php @@ -0,0 +1,68 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only user can add ticket comments.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddCommentCommand::class, 'json', ['groups' => 'write']); + + $errors = $this->validator->validate($command); + + if (count($errors) > 0) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->addCommentCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_CREATED, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php index d62e6d8b4..de12d5d47 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php @@ -31,12 +31,14 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface private ?int $id = null; public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + private string $content, #[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')] #[JoinColumn(nullable: false)] private Ticket $ticket, - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] - private string $content = '' - ) {} + ) { + $ticket->addComment($this); + } public function getId(): ?int { diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index de369379b..cd49d7377 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -104,16 +104,27 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface ->getValues(); } + /** + * @internal use @see{Comment::__construct} instead + */ + public function addComment(Comment $comment): void + { + $this->comments->add($comment); + } + /** * Add a PersonHistory. * - * This method should not be used, use @see{PersonHistory::__construct()} insted. + * @internal use @see{PersonHistory::__construct} instead */ public function addPersonHistory(PersonHistory $personHistory): void { $this->personHistories->add($personHistory); } + /** + * @internal use @see{MotiveHistory::__construct} instead + */ public function addMotiveHistory(MotiveHistory $motiveHistory): void { $this->motiveHistories->add($motiveHistory); diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php new file mode 100644 index 000000000..5ff18b436 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php @@ -0,0 +1,52 @@ +buildCommand(); + + $ticket = new Ticket(); + $command = new AddCommentCommand(content: 'test'); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getComments()); + self::assertEquals('test', $ticket->getComments()[0]->getContent()); + } + + private function buildCommand(): AddCommentCommandHandler + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + + return new AddCommentCommandHandler($entityManager->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php new file mode 100644 index 000000000..2826c360d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php @@ -0,0 +1,104 @@ +validator = self::getContainer()->get(ValidatorInterface::class); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testAddComment(): void + { + $controller = $this->buildController(willFlush: true); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": "test"} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(201, $response->getStatusCode()); + } + + public function testAddCommentWithBlankContent(): void + { + $controller = $this->buildController(willFlush: false); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": ""} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $request = new Request(content: <<<'JSON' + {"content": null} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + private function buildController(bool $willFlush): AddCommentController + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = new AddCommentCommandHandler($entityManager->reveal()); + + return new AddCommentController( + $security->reveal(), + $this->serializer, + $this->validator, + $commandHandler, + $entityManager->reveal(), + ); + } +} From 39d701feb2a60c212431c06042403b89da6adecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 18 Apr 2024 22:10:56 +0200 Subject: [PATCH 03/12] Serialize ticket's Comment --- .../ChillTicketBundle/src/Entity/Comment.php | 4 ++++ .../src/Resources/public/types.ts | 13 ++++++++++++- .../Serializer/Normalizer/TicketNormalizer.php | 10 ++++++++++ .../Normalizer/TicketNormalizerTest.php | 18 +++++++++++++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php index de12d5d47..8e11ff489 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php @@ -17,9 +17,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\JoinColumn; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity()] #[ORM\Table(name: 'comment', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_comment' => Comment::class])] class Comment implements TrackCreationInterface, TrackUpdateInterface { use TrackCreationTrait; @@ -28,10 +30,12 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] private ?int $id = null; public function __construct( #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + #[Serializer\Groups(['read'])] private string $content, #[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')] #[JoinColumn(nullable: false)] diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index b0c6a37df..2df53da99 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -36,10 +36,21 @@ interface MotiveHistory { createdAt: DateTime|null, } +interface Comment { + type: "ticket_comment", + id: number, + content: string, + createdBy: User|null, + createdAt: DateTime|null, + updatedBy: User|null, + updatedAt: DateTime|null, +} + interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; +interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {}; interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {}; -type TicketHistoryLine = AddPersonEvent | SetMotiveEvent; +type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent; export interface Ticket { type: "ticket_ticket" diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index c69ac5a7f..c68ab34ef 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Serializer\Normalizer; +use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\Ticket; @@ -69,6 +70,15 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ], $ticket->getPersonHistories()->toArray(), ), + ...array_map( + fn (Comment $comment) => [ + 'event_type' => 'add_comment', + 'at' => $comment->getCreatedAt(), + 'by' => $comment->getCreatedBy(), + 'data' => $comment, + ], + $ticket->getComments()->toArray(), + ), ]; usort( diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index e876341cd..447bebfbf 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -11,7 +11,9 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Serializer\Normalizer; +use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -93,11 +95,20 @@ class TicketNormalizerTest extends KernelTestCase // datetime $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) ->will(function ($args) { return $args[0]->getTimestamp(); }); + // user + $normalizer->normalize(Argument::type(User::class), 'json', Argument::type('array')) + ->willReturn(['user']); + // motive $normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]); + // person history $normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array')) ->willReturn(['personHistory']); + // motive history $normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array')) ->willReturn(['motiveHistory']); + $normalizer->normalize(Argument::type(Comment::class), 'json', Argument::type('array')) + ->willReturn(['comment']); + // null values $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); $ticketNormalizer = new TicketNormalizer(); @@ -109,6 +120,7 @@ class TicketNormalizerTest extends KernelTestCase public static function provideTickets(): iterable { yield [ + // this a nearly empty ticket new Ticket(), [ 'type' => 'ticket_ticket', @@ -122,10 +134,14 @@ class TicketNormalizerTest extends KernelTestCase ], ]; + // ticket with more features $ticket = new Ticket(); $ticket->setExternalRef('2134'); $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); $ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); + $comment = new Comment('blabla test', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); yield [ $ticket, @@ -137,7 +153,7 @@ class TicketNormalizerTest extends KernelTestCase 'currentAddressees' => [], 'currentInputs' => [], 'currentMotive' => ['type' => 'motive', 'id' => 0], - 'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive']], + 'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive'], ['event_type' => 'add_comment']], ], ]; } From e2deb55fdbee985636d5862c7d98cab4d39afc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 19 Apr 2024 15:34:16 +0200 Subject: [PATCH 04/12] Create api endpoint for listing user-group --- .../Controller/UserGroupApiController.php | 16 ++++++++++++++++ .../DependencyInjection/ChillMainExtension.php | 17 +++++++++++++++++ src/Bundle/ChillMainBundle/Entity/UserGroup.php | 4 ++++ src/Bundle/ChillMainBundle/chill.api.specs.yaml | 16 ++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php diff --git a/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php new file mode 100644 index 000000000..602b84ec5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php @@ -0,0 +1,16 @@ + UserGroup::class, + 'controller' => UserGroupApiController::class, + 'name' => 'user-group', + 'base_path' => '/api/1.0/main/user-group', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ], + ], + ], + ], ], ]); } diff --git a/src/Bundle/ChillMainBundle/Entity/UserGroup.php b/src/Bundle/ChillMainBundle/Entity/UserGroup.php index 3bd9870a6..d6e343d00 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserGroup.php +++ b/src/Bundle/ChillMainBundle/Entity/UserGroup.php @@ -14,17 +14,21 @@ namespace Chill\MainBundle\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'chill_main_user_group')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['user_group' => UserGroup::class])] class UserGroup { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[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 = []; /** diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index f37ee723d..fec36312d 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -908,3 +908,19 @@ paths: $ref: '#/components/schemas/NewsItem' 403: description: "Unauthorized" + /1.0/main/user-group.json: + get: + tags: + - user-group + summary: Return a list of users-groups + responses: + 200: + description: "ok" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/NewsItem' + 403: + description: "Unauthorized" From 46fb1c04b57cd6fd4224e389e8d6720e67027ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 22 Apr 2024 12:01:28 +0200 Subject: [PATCH 05/12] Add color and exclusion fields to UserGroup This commit introduces new fields to the UserGroup entity, specifically background color, foreground color, and an exclusion key. These have been implemented both in the PHP entity and TypeScript interface definitions. Additionally, a Doctrine migration has been created to reflect these changes on the database side. --- .../ChillMainBundle/Entity/UserGroup.php | 61 +++++++++++++++++++ .../ChillMainBundle/Resources/public/types.ts | 9 +++ .../migrations/Version20240422091752.php | 41 +++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20240422091752.php diff --git a/src/Bundle/ChillMainBundle/Entity/UserGroup.php b/src/Bundle/ChillMainBundle/Entity/UserGroup.php index d6e343d00..470a21a70 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserGroup.php +++ b/src/Bundle/ChillMainBundle/Entity/UserGroup.php @@ -38,6 +38,24 @@ class UserGroup #[ORM\JoinTable(name: 'chill_main_user_group_user')] private Collection $users; + #[ORM\Column(type: 'text', nullable: false, options: ['default' => '#ffffffff'])] + #[Serializer\Groups(['read'])] + private string $backgroundColor = '#ffffffff'; + + #[ORM\Column(type: 'text', nullable: false, options: ['default' => '#000000ff'])] + #[Serializer\Groups(['read'])] + private string $foregroundColor = '#000000ff'; + + /** + * Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship + * will exclude others. + * + * An empty string means "no exclusion" + */ + #[ORM\Column(type: 'text', nullable: false, options: ['default' => ''])] + #[Serializer\Groups(['read'])] + private string $excludeKey = ''; + public function __construct() { $this->users = new ArrayCollection(); @@ -75,4 +93,47 @@ class UserGroup { return $this->users; } + + public function getForegroundColor(): string + { + return $this->foregroundColor; + } + + public function getExcludeKey(): string + { + return $this->excludeKey; + } + + public function getBackgroundColor(): string + { + return $this->backgroundColor; + } + + public function setForegroundColor(string $foregroundColor): self + { + $this->foregroundColor = $foregroundColor; + + return $this; + } + + public function setBackgroundColor(string $backgroundColor): self + { + $this->backgroundColor = $backgroundColor; + + return $this; + } + + public function setExcludeKey(string $excludeKey): self + { + $this->excludeKey = $excludeKey; + + return $this; + } + + public function setLabel(array $label): self + { + $this->label = $label; + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 2e33b8248..840a0e939 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -42,6 +42,15 @@ export interface User { // todo: mainCenter; mainJob; etc.. } +export interface UserGroup { + type: "chill_main_user_group", + id: number, + label: TranslatableString, + backgroundColor: string, + foregroundColor: string, + excludeKey: string, +} + export interface UserAssociatedInterface { type: "user"; id: number; diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php new file mode 100644 index 000000000..960b3cc79 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE chill_main_user_group ADD backgroundColor TEXT DEFAULT \'#ffffffff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD foregroundColor TEXT DEFAULT \'#000000ff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD excludeKey TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER INDEX idx_1e07f044d2112630 RENAME TO IDX_738BC82BD2112630'); + $this->addSql('ALTER INDEX idx_1e07f044a76ed395 RENAME TO IDX_738BC82BA76ED395'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_user_group DROP backgroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP foregroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP excludeKey'); + $this->addSql('ALTER INDEX idx_738bc82bd2112630 RENAME TO idx_1e07f044d2112630'); + $this->addSql('ALTER INDEX idx_738bc82ba76ed395 RENAME TO idx_1e07f044a76ed395'); + } +} From 0bc6e62d4d6d29be4c593990cc956edfb4be226e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 22 Apr 2024 12:01:49 +0200 Subject: [PATCH 06/12] Add fixtures for UserGroup --- .../DataFixtures/ORM/LoadUserGroup.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php diff --git a/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php new file mode 100644 index 000000000..614a83515 --- /dev/null +++ b/src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadUserGroup.php @@ -0,0 +1,68 @@ +getRepository(User::class)->findOneBy(['username' => 'center a_social']); + $centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']); + $multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']); + $administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']); + $administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']); + + $level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level'); + $level1->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($level1); + + $level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level'); + $level2->addUser($multiCenter); + $manager->persist($level2); + + $level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level'); + $level3->addUser($multiCenter); + $manager->persist($level3); + + $tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', ''); + $tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($tss); + $admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', ''); + $admins->addUser($administrativeA)->addUser($administrativeB); + $manager->persist($admins); + + $manager->flush(); + } + + private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup + { + $userGroup = new UserGroup(); + + return $userGroup + ->setLabel(['fr' => $title]) + ->setBackgroundColor($backgroundColor) + ->setForegroundColor($foregroundColor) + ->setExcludeKey($excludeKey) + ; + } +} From 9f355032a818dfc2659451fa0652801f393fb90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 22 Apr 2024 12:41:43 +0200 Subject: [PATCH 07/12] Create a "do not exclude" validation constraint for user groups --- .../Validator/UserGroupDoNotExcludeTest.php | 91 +++++++++++++++++++ .../Constraint/UserGroupDoNotExclude.php | 31 +++++++ .../Validator/UserGroupDoNotExclude.php | 69 ++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php create mode 100644 src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php create mode 100644 src/Bundle/ChillMainBundle/Validation/Validator/UserGroupDoNotExclude.php diff --git a/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php new file mode 100644 index 000000000..d6d1c9887 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php @@ -0,0 +1,91 @@ +validator->validate([], new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()); + + $this->assertNoViolation(); + } + + public function testMixedUserGroupAndUsersIsValid(): void + { + $this->validator->validate( + [new User(), new UserGroup()], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testDifferentExcludeKeysIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey('A'), (new UserGroup())->setExcludeKey('B')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testMultipleGroupsWithEmptyExcludeKeyIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey(''), (new UserGroup())->setExcludeKey('')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testSameExclusionKeyWillRaiseError(): void + { + $this->validator->validate( + [ + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 1']), + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 2']), + ], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->buildViolation('The groups {{ excluded_groups }} do exclude themselves. Please choose one between them') + ->setParameter('excluded_groups', 'Group 1, Group 2') + ->setCode('e16c8226-0090-11ef-8560-f7239594db09') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php new file mode 100644 index 000000000..5ec688e5b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php @@ -0,0 +1,31 @@ +getExcludeKey()][] = $gr; + } + } + + foreach ($groups as $excludeKey => $groupByKey) { + if ('' === $excludeKey) { + continue; + } + + if (1 < count($groupByKey)) { + $excludedGroups = implode( + ', ', + array_map( + fn (UserGroup $group) => $this->translatableStringHelper->localize($group->getLabel()), + $groupByKey + ) + ); + + $this->context + ->buildViolation($constraint->message) + ->setCode($constraint->code) + ->setParameters(['excluded_groups' => $excludedGroups]) + ->addViolation(); + } + } + } +} From b434d38091508a6fda2ae1e7abb365975322746f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 23 Apr 2024 17:47:07 +0200 Subject: [PATCH 08/12] Add functionality to set addressees for a ticket This update includes the implementation of methods to add and retrieve addressee history in the Ticket entity, a handler for addressee setting command, denormalizer for transforming request data to SetAddresseesCommand, and corresponding tests. Additionally, it adds a SetAddresseesController for handling addressee related requests and updates the API specifications. --- .../ChillMainBundle/chill.api.specs.yaml | 38 ++++- .../config/services/validator.yaml | 3 + .../ChillTicketBundle/chill.api.specs.yaml | 36 +++++ .../Handler/SetAddresseesCommandHandler.php | 50 ++++++ .../Action/Ticket/SetAddresseesCommand.php | 31 ++++ .../Controller/SetAddresseesController.php | 67 ++++++++ .../src/Entity/AddresseeHistory.php | 9 ++ .../src/Entity/PersonHistory.php | 7 + .../ChillTicketBundle/src/Entity/Ticket.php | 16 ++ .../SetAddresseesCommandDenormalizer.php | 56 +++++++ .../src/config/services.yaml | 6 +- .../SetAddressesCommandHandlerTest.php | 118 ++++++++++++++ .../SetAddresseesControllerTest.php | 146 ++++++++++++++++++ .../tests/Entity/TicketTest.php | 31 ++++ .../SetAddresseesCommandDenormalizerTest.php | 66 ++++++++ 15 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php create mode 100644 src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index fec36312d..958afab07 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -29,6 +29,42 @@ components: type: string text: type: string + UserById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + UserGroup: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group + label: + type: object + additionalProperties: true + backgroundColor: + type: string + foregroundColor: + type: string + exclusionKey: + type: string + UserGroupById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group Center: type: object properties: @@ -921,6 +957,6 @@ paths: schema: type: array items: - $ref: '#/components/schemas/NewsItem' + $ref: '#/components/schemas/UserGroup' 403: description: "Unauthorized" diff --git a/src/Bundle/ChillMainBundle/config/services/validator.yaml b/src/Bundle/ChillMainBundle/config/services/validator.yaml index b3b60b9d6..32b8903cc 100644 --- a/src/Bundle/ChillMainBundle/config/services/validator.yaml +++ b/src/Bundle/ChillMainBundle/config/services/validator.yaml @@ -3,6 +3,9 @@ services: autowire: true autoconfigure: true + Chill\MainBundle\Validation\: + resource: '../../Validation' + chill_main.validator_user_circle_consistency: class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator arguments: diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 86049cc24..232982f01 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -93,3 +93,39 @@ paths: description: "ACCEPTED" 422: description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/addressees/set: + post: + tags: + - ticket + summary: Set the addresses for an existing ticket (will replace all the existing addresses) + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + addresses: + type: array + items: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php new file mode 100644 index 000000000..25ac97e5d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php @@ -0,0 +1,50 @@ +getAddresseeHistories() as $addressHistory) { + if (null !== $addressHistory->getEndDate()) { + continue; + } + + if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) { + $addressHistory->setEndDate($this->clock->now()); + } + } + + // add new addresses + foreach ($command->addressees as $address) { + if (in_array($address, $ticket->getCurrentAddressee(), true)) { + continue; + } + + $history = new AddresseeHistory($address, $this->clock->now(), $ticket); + $this->entityManager->persist($history); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php new file mode 100644 index 000000000..824e2506f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php @@ -0,0 +1,31 @@ + + */ + #[UserGroupDoNotExclude] + #[GreaterThan(0)] + #[Groups(['read'])] + public array $addressees + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php new file mode 100644 index 000000000..add8d480a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -0,0 +1,67 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can set addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + if (0 < count($errors = $this->validator->validate($command))) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->addressesCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true, + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php index 3939a24c3..b3718a9ca 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php @@ -54,6 +54,8 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface } else { $this->addresseeGroup = $addressee; } + + $this->ticket->addAddresseeHistory($this); } public function getAddressee(): UserGroup|User @@ -94,4 +96,11 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface { return $this->ticket; } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php index 57a9809e2..927ea978f 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php @@ -84,4 +84,11 @@ class PersonHistory implements TrackCreationInterface { return $this->removedBy; } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index cd49d7377..04cfe828b 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -130,6 +130,14 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface $this->motiveHistories->add($motiveHistory); } + /** + * @internal use @see{AddresseHistory::__construct} instead + */ + public function addAddresseeHistory(AddresseeHistory $addresseeHistory): void + { + $this->addresseeHistory->add($addresseeHistory); + } + /** * @return list */ @@ -195,4 +203,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface { return $this->personHistories; } + + /** + * @return ReadableCollection + */ + public function getAddresseeHistories(): ReadableCollection + { + return $this->addresseeHistory; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php new file mode 100644 index 000000000..d97ca5d92 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php @@ -0,0 +1,56 @@ + $this->denormalizer->denormalize($address, UserGroup::class, $format, $context), + 'user' => $this->denormalizer->denormalize($address, User::class, $format, $context), + default => throw new UnexpectedValueException('the type is not set or not supported') + }; + } + + return new SetAddresseesCommand($addresses); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return SetAddresseesCommand::class === $type && 'json' === $format; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 490e215c4..006fa5cb6 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -14,5 +14,7 @@ services: Chill\TicketBundle\Serializer\: resource: '../Serializer/' - Chill\TicketBundle\DataFixtures\: - resource: '../DataFixtures/' +when@dev: + services: + Chill\TicketBundle\DataFixtures\: + resource: '../DataFixtures/' diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php new file mode 100644 index 000000000..efb545bac --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -0,0 +1,118 @@ +prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function ($arg) use ($user1) { + return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user1; + }))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(function ($arg) use ($group1) { + return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group1; + }))->shouldBeCalledOnce(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(2, $ticket->getCurrentAddressee()); + } + + public function testHandleExistingUserIsNotRemovedNorCreatingDouble(): void + { + $ticket = new Ticket(); + $user = new User(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $command = new SetAddresseesCommand([$user]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function ($arg) use ($user) { + return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user; + }))->shouldNotBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNull($history->getEndDate()); + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + public function testHandleRemoveExistingAddressee(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $command = new SetAddresseesCommand([$group]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function ($arg) use ($group) { + return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group; + }))->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNotNull($history->getEndDate()); + self::assertContains($group, $ticket->getCurrentAddressee()); + } + + public function testAddingDoublingAddresseeDoesNotCreateDoubleHistories(): void + { + $ticket = new Ticket(); + $group = new UserGroup(); + $command = new SetAddresseesCommand([$group, $group]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(function ($arg) use ($group) { + return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group; + }))->shouldBeCalledOnce(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + private function buildHandler(EntityManagerInterface $entityManager): SetAddresseesCommandHandler + { + return new SetAddresseesCommandHandler(new MockClock(), $entityManager); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php new file mode 100644 index 000000000..ed842f742 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -0,0 +1,146 @@ +serializer = self::getContainer()->get(SerializerInterface::class); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations', $asArray); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + public static function getContentData(): iterable + { + self::bootKernel(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [[['type' => 'user', 'id' => $user->getId()]]]; + yield [[['type' => 'user', 'id' => $user->getId()], ['type' => 'user_group', 'id' => $userGroup->getId()]]]; + yield [[['type' => 'user_group', 'id' => $userGroup->getId()]]]; + } + + private function buildController(bool $willSave, bool $isValid): SetAddresseesController + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willSave) { + $entityManager->flush()->shouldBeCalled(); + $entityManager->persist(Argument::type(AddresseeHistory::class))->shouldBeCalled(); + } + + $validator = $this->prophesize(ValidatorInterface::class); + + if ($isValid) { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn(new ConstraintViolationList([])); + } else { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn( + new ConstraintViolationList([ + new ConstraintViolation('Fake constraint', 'fake message template', [], [], 'addresses', []), + ]) + ); + } + + return new SetAddresseesController( + $security->reveal(), + $entityManager->reveal(), + $this->serializer, + new SetAddresseesCommandHandler(new MockClock(), $entityManager->reveal()), + $validator->reveal() + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index 6f710f1aa..f0efe5493 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -11,7 +11,10 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Entity; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -57,5 +60,33 @@ class TicketTest extends KernelTestCase self::assertCount(1, $ticket->getPersons()); self::assertSame($person, $ticket->getPersons()[0]); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(0, $ticket->getPersons()); + } + + public function testGetAddresse(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + + self::assertEquals([], $ticket->getCurrentAddressee()); + + $history = new AddresseeHistory($user, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($user, $ticket->getCurrentAddressee()[0]); + + $history2 = new AddresseeHistory($group, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(2, $ticket->getCurrentAddressee()); + self::assertContains($group, $ticket->getCurrentAddressee()); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($group, $ticket->getCurrentAddressee()[0]); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php new file mode 100644 index 000000000..a4b9bbf49 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php @@ -0,0 +1,66 @@ +supportsDenormalization('', SetAddresseesCommand::class, 'json')); + self::assertFalse($denormalizer->supportsDenormalization('', stdClass::class, 'json')); + } + + public function testDenormalize() + { + $denormalizer = $this->buildDenormalizer(); + + $actual = $denormalizer->denormalize(['addressees' => [['type' => 'user'], ['type' => 'user_group']]], SetAddresseesCommand::class, 'json'); + + self::assertInstanceOf(SetAddresseesCommand::class, $actual); + self::assertIsArray($actual->addressees); + self::assertCount(2, $actual->addressees); + self::assertInstanceOf(User::class, $actual->addressees[0]); + self::assertInstanceOf(UserGroup::class, $actual->addressees[1]); + } + + private function buildDenormalizer(): SetAddresseesCommandDenormalizer + { + $normalizer = $this->prophesize(DenormalizerInterface::class); + $normalizer->denormalize(Argument::any(), User::class, 'json', Argument::any()) + ->willReturn(new User()); + $normalizer->denormalize(Argument::any(), UserGroup::class, 'json', Argument::any()) + ->willReturn(new UserGroup()); + + $denormalizer = new SetAddresseesCommandDenormalizer(); + $denormalizer->setDenormalizer($normalizer->reveal()); + + return $denormalizer; + } +} From fa6783569045615b3a0487972adf08c1d4caf095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 23 Apr 2024 23:00:12 +0200 Subject: [PATCH 09/12] Add functionality to add single addressee to tickets This update introduces a new feature allowing end-users to add a single addressee to a ticket without removing the existing ones. This was achieved by adding a new API endpoint and updating the SetAddresseesController to handle the addition of a single addressee. Accompanying tests have also been provided to ensure the new feature works as expected. --- .../ChillTicketBundle/chill.api.specs.yaml | 34 +++++++++- .../src/Action/Ticket/AddAddresseeCommand.php | 29 +++++++++ .../Action/Ticket/SetAddresseesCommand.php | 9 +++ .../Controller/SetAddresseesController.php | 26 ++++++-- .../SetAddresseesControllerTest.php | 64 +++++++++++++++++++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 232982f01..5be9ac0c0 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -115,7 +115,7 @@ paths: schema: type: object properties: - addresses: + addressees: type: array items: oneOf: @@ -129,3 +129,35 @@ paths: 422: description: "UNPROCESSABLE ENTITY" + /1.0/ticket/{id}/addressee/add: + post: + tags: + - ticket + summary: Add an addressee to a ticket, without removing existing ones. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + addressee: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php new file mode 100644 index 000000000..d7f9c545f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php @@ -0,0 +1,29 @@ +addressee, + ...$ticket->getCurrentAddressee(), + ]); + } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php index add8d480a..9fa6dc366 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Controller; +use Chill\TicketBundle\Action\Ticket\AddAddresseeCommand; use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler; use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; use Chill\TicketBundle\Entity\Ticket; @@ -28,11 +29,11 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; final readonly class SetAddresseesController { public function __construct( - private Security $security, - private EntityManagerInterface $entityManager, - private SerializerInterface $serializer, + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, private SetAddresseesCommandHandler $addressesCommandHandler, - private ValidatorInterface $validator, + private ValidatorInterface $validator, ) {} #[Route('/api/1.0/ticket/{id}/addressees/set', methods: ['POST'])] @@ -44,6 +45,23 @@ final readonly class SetAddresseesController $command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + return $this->registerSetAddressees($command, $ticket); + } + + #[Route('/api/1.0/ticket/{id}/addressee/add', methods: ['POST'])] + public function addAddressee(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can add addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddAddresseeCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetAddressees(SetAddresseesCommand::fromAddAddresseeCommand($command, $ticket), $ticket); + } + + private function registerSetAddressees(SetAddresseesCommand $command, Ticket $ticket): Response + { if (0 < count($errors = $this->validator->validate($command))) { return new JsonResponse( $this->serializer->serialize($errors, 'json'), diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php index ed842f742..a7ca214c1 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -85,6 +85,70 @@ class SetAddresseesControllerTest extends KernelTestCase self::assertGreaterThan(0, count($asArray['violations'])); } + /** + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @throws \JsonException + * + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations', $asArray); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + public static function getContentDataUnique(): iterable + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [['type' => 'user', 'id' => $user->getId()]]; + yield [['type' => 'user_group', 'id' => $userGroup->getId()]]; + } + public static function getContentData(): iterable { self::bootKernel(); From ed45f14a45538bba59deeabc810efd5b4ce73309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 23 Apr 2024 23:38:34 +0200 Subject: [PATCH 10/12] Add tracking of addressee history in ticket system The updates introduce tracking for the history of addressees in the ticket system, both when added and when removed. The user who removed an addressee is now recorded. The changes also ensure these updated aspects are correctly normalized and users can see them in the ticket history. A new database migration file was created for the changes. --- .../Handler/SetAddresseesCommandHandler.php | 6 +++ .../src/migrations/Version20240423212824.php | 40 +++++++++++++++++++ .../SetAddressesCommandHandlerTest.php | 6 ++- .../SetAddresseesControllerTest.php | 4 +- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php index 25ac97e5d..b2805ed2f 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php @@ -11,17 +11,20 @@ declare(strict_types=1); namespace Chill\TicketBundle\Action\Ticket\Handler; +use Chill\MainBundle\Entity\User; use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Ticket; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Security\Core\Security; final readonly class SetAddresseesCommandHandler { public function __construct( private ClockInterface $clock, private EntityManagerInterface $entityManager, + private Security $security, ) {} public function handle(Ticket $ticket, SetAddresseesCommand $command): void @@ -34,6 +37,9 @@ final readonly class SetAddresseesCommandHandler if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) { $addressHistory->setEndDate($this->clock->now()); + if (($user = $this->security->getUser()) instanceof User) { + $addressHistory->setRemovedBy($user); + } } } diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php new file mode 100644 index 000000000..3c025d763 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE chill_ticket.addressee_history ADD endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT null'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD removedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBDB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_434EBDBDB8346CCF ON chill_ticket.addressee_history (removedBy_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBDB8346CCF'); + $this->addSql('DROP INDEX chill_ticket.IDX_434EBDBDB8346CCF'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP endDate'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP removedBy_id'); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php index efb545bac..cef1ae153 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -22,6 +22,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Clock\MockClock; +use Symfony\Component\Security\Core\Security; /** * @internal @@ -113,6 +114,9 @@ final class SetAddressesCommandHandlerTest extends TestCase private function buildHandler(EntityManagerInterface $entityManager): SetAddresseesCommandHandler { - return new SetAddresseesCommandHandler(new MockClock(), $entityManager); + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn(new User()); + + return new SetAddresseesCommandHandler(new MockClock(), $entityManager, $security->reveal()); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php index a7ca214c1..af0d48e87 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -177,8 +177,10 @@ class SetAddresseesControllerTest extends KernelTestCase private function buildController(bool $willSave, bool $isValid): SetAddresseesController { + $user = new User(); $security = $this->prophesize(Security::class); $security->isGranted('ROLE_USER')->willReturn(true); + $security->getUser()->willReturn($user); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -203,7 +205,7 @@ class SetAddresseesControllerTest extends KernelTestCase $security->reveal(), $entityManager->reveal(), $this->serializer, - new SetAddresseesCommandHandler(new MockClock(), $entityManager->reveal()), + new SetAddresseesCommandHandler(new MockClock(), $entityManager->reveal(), $security->reveal()), $validator->reveal() ); } From 45828174d1156c45a86cd16fcdb4f2d5639cce16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 23 Apr 2024 23:39:01 +0200 Subject: [PATCH 11/12] Add addressee history to ticket serialization This update extends the tickets serialization and normalisation process to include addressee history. With the changes, AddresseeHistory class now also keeps track of who removed an addressee. Additional types, tests and interfaces have been introduced to support this change. --- .../ChillMainBundle/Resources/public/types.ts | 2 ++ .../src/Entity/AddresseeHistory.php | 24 +++++++++++++ .../src/Resources/public/types.ts | 35 +++++++++++++++---- .../Normalizer/TicketNormalizer.php | 19 ++++++++++ .../Normalizer/TicketNormalizerTest.php | 18 ++++++++-- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 840a0e939..ada3089a0 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -51,6 +51,8 @@ export interface UserGroup { excludeKey: string, } +export type UserGroupOrUser = User | UserGroup; + export interface UserAssociatedInterface { type: "user"; id: number; diff --git a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php index b3718a9ca..2717df2a5 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php @@ -18,9 +18,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity()] #[ORM\Table(name: 'addressee_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_addressee_history' => AddresseeHistory::class])] class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface { use TrackCreationTrait; @@ -29,6 +31,7 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class)] @@ -39,11 +42,19 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface #[ORM\JoinColumn(nullable: true)] private ?UserGroup $addresseeGroup = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => 'null'])] + #[Serializer\Groups(['read'])] private ?\DateTimeImmutable $endDate = null; + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?User $removedBy = null; + public function __construct( User|UserGroup $addressee, #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] private \DateTimeImmutable $startDate, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] @@ -58,6 +69,7 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface $this->ticket->addAddresseeHistory($this); } + #[Serializer\Groups(['read'])] public function getAddressee(): UserGroup|User { if (null !== $this->addresseeGroup) { @@ -97,6 +109,18 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface return $this->ticket; } + public function getRemovedBy(): ?User + { + return $this->removedBy; + } + + public function setRemovedBy(?User $removedBy): self + { + $this->removedBy = $removedBy; + + return $this; + } + public function setEndDate(?\DateTimeImmutable $endDate): self { $this->endDate = $endDate; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index 2df53da99..0bcd1ecbb 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -1,4 +1,10 @@ -import {DateTime, TranslatableString, User} from "../../../../ChillMainBundle/Resources/public/types"; +import { + DateTime, + TranslatableString, + User, + UserGroup, + UserGroupOrUser +} from "../../../../ChillMainBundle/Resources/public/types"; import {Person} from "../../../../ChillPersonBundle/Resources/public/types"; export interface Motive { @@ -46,18 +52,33 @@ interface Comment { updatedAt: DateTime|null, } +interface AddresseeHistory { + type: "ticket_addressee_history", + id: number, + startDate: DateTime|null, + addressee: UserGroupOrUser, + endDate: DateTime|null, + removedBy: User|null, + createdBy: User|null, + createdAt: DateTime|null, + updatedBy: User|null, + updatedAt: DateTime|null, +} + interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {}; interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {}; +interface AddAddressee extends TicketHistory<"add_addressee", AddresseeHistory> {}; -type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent; +type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent | AddAddressee; export interface Ticket { - type: "ticket_ticket" - id: number - externalRef: string - currentPersons: Person[] - currentMotive: null|Motive + type: "ticket_ticket", + id: number, + externalRef: string, + currentAddressees: UserGroupOrUser[], + currentPersons: Person[], + currentMotive: null|Motive, history: TicketHistoryLine[], } diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index c68ab34ef..f7aff602b 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Serializer\Normalizer; +use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -79,6 +80,24 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ], $ticket->getComments()->toArray(), ), + ...array_map( + fn (AddresseeHistory $history) => [ + 'event_type' => 'add_addressee', + 'at' => $history->getStartDate(), + 'by' => $history->getCreatedBy(), + 'data' => $history, + ], + $ticket->getAddresseeHistories()->toArray(), + ), + ...array_map( + fn (AddresseeHistory $history) => [ + 'event_type' => 'remove_addressee', + 'at' => $history->getStartDate(), + 'by' => $history->getRemovedBy(), + 'data' => $history, + ], + $ticket->getAddresseeHistories()->filter(fn (AddresseeHistory $history) => null !== $history->getEndDate())->toArray() + ), ]; usort( diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index 447bebfbf..415fc15fb 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -12,7 +12,9 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Serializer\Normalizer; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; @@ -108,6 +110,8 @@ class TicketNormalizerTest extends KernelTestCase ->willReturn(['motiveHistory']); $normalizer->normalize(Argument::type(Comment::class), 'json', Argument::type('array')) ->willReturn(['comment']); + $normalizer->normalize(Argument::type(AddresseeHistory::class), 'json', Argument::type('array')) + ->willReturn(['addresseeHistory']); // null values $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); @@ -142,6 +146,9 @@ class TicketNormalizerTest extends KernelTestCase $comment = new Comment('blabla test', $ticket); $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); $comment->setCreatedBy(new User()); + $addresseeHistory = new AddresseeHistory(new User(), new \DateTimeImmutable('2024-04-01T12:05:00'), $ticket); + $addresseeHistory->setEndDate(new \DateTimeImmutable('2024-04-01T12:06:00')); + new AddresseeHistory(new UserGroup(), new \DateTimeImmutable('2024-04-01T12:07:00'), $ticket); yield [ $ticket, @@ -150,10 +157,17 @@ class TicketNormalizerTest extends KernelTestCase 'id' => null, 'externalRef' => '2134', 'currentPersons' => ['embedded'], - 'currentAddressees' => [], + 'currentAddressees' => ['embedded'], 'currentInputs' => [], 'currentMotive' => ['type' => 'motive', 'id' => 0], - 'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive'], ['event_type' => 'add_comment']], + 'history' => [ + ['event_type' => 'add_person'], + ['event_type' => 'set_motive'], + ['event_type' => 'add_comment'], + ['event_type' => 'add_addressee'], + ['event_type' => 'remove_addressee'], + ['event_type' => 'add_addressee'], + ], ], ]; } From 2d8b960d9e59730f3874787231b8a7731726018c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 24 Apr 2024 18:48:00 +0200 Subject: [PATCH 12/12] Re-open the same ticket if a ticket already exists with the same externalRef, instead of creating a new one --- .../src/Controller/CreateTicketController.php | 12 +++- .../src/Repository/TicketRepository.php | 56 +++++++++++++++++++ .../Repository/TicketRepositoryInterface.php | 23 ++++++++ .../src/config/services.yaml | 7 ++- 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php create mode 100644 src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php index 8a6d8dd32..a3bdf31c8 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand; use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -31,7 +32,8 @@ final readonly class CreateTicketController private AssociateByPhonenumberCommandHandler $associateByPhonenumberCommandHandler, private Security $security, private UrlGeneratorInterface $urlGenerator, - private EntityManagerInterface $entityManager + private EntityManagerInterface $entityManager, + private TicketRepositoryInterface $ticketRepository, ) {} #[Route('{_locale}/ticket/ticket/create')] @@ -41,6 +43,14 @@ final readonly class CreateTicketController throw new AccessDeniedHttpException('Only users are allowed to create tickets.'); } + if ('' !== $extId = $request->query->get('extId', '')) { + if (null !== $ticket = $this->ticketRepository->findOneByExternalRef($extId)) { + return new RedirectResponse( + $this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId()]) + ); + } + } + $createCommand = new CreateTicketCommand($request->query->get('extId', '')); $ticket = $this->createTicketCommandHandler->__invoke($createCommand); diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php new file mode 100644 index 000000000..d4301585a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php @@ -0,0 +1,56 @@ +repository = $objectManager->getRepository($this->getClassName()); + } + + public function find($id): ?Ticket + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Ticket + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName() + { + return Ticket::class; + } + + public function findOneByExternalRef(string $extId): ?Ticket + { + return $this->repository->findOneBy(['externalRef' => $extId]); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php new file mode 100644 index 000000000..0b2cb4d66 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ +interface TicketRepositoryInterface extends ObjectRepository +{ + public function findOneByExternalRef(string $extId): ?Ticket; +} diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 006fa5cb6..4b662ecdc 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -3,13 +3,16 @@ services: autoconfigure: true autowire: true + Chill\TicketBundle\Action\Ticket\Handler\: + resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Controller\: resource: '../Controller/' tags: - controller.service_arguments - Chill\TicketBundle\Action\Ticket\Handler\: - resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Repository\: + resource: '../Repository/' Chill\TicketBundle\Serializer\: resource: '../Serializer/'