From 0a331aab37e1144d80b2294341c424d9931b5012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 24 Jun 2025 10:42:51 +0000 Subject: [PATCH] Record that a ticket can be in emergency, or not --- .junie/guidelines.md | 32 +++- .../ChillTicketBundle/chill.api.specs.yaml | 33 ++++ .../Ticket/ChangeEmergencyStateCommand.php | 24 +++ .../ChangeEmergencyStateCommandHandler.php | 49 ++++++ .../Handler/CreateTicketCommandHandler.php | 4 + .../Handler/ReplaceMotiveCommandHandler.php | 8 + .../ChangeEmergencyStateApiController.php | 73 +++++++++ .../src/Entity/EmergencyStatusEnum.php | 21 +++ .../src/Entity/EmergencyStatusHistory.php | 85 +++++++++++ .../ChillTicketBundle/src/Entity/Motive.php | 19 +++ .../ChillTicketBundle/src/Entity/Ticket.php | 34 +++++ .../src/Resources/public/types.ts | 3 + .../Normalizer/TicketNormalizer.php | 15 +- .../src/migrations/Version20250620145414.php | 82 ++++++++++ .../src/migrations/Version20250620164517.php | 37 +++++ ...ChangeEmergencyStateCommandHandlerTest.php | 118 ++++++++++++++ .../CreateTicketCommandHandlerTest.php | 3 + .../ReplaceMotiveCommandHandlerTest.php | 75 ++++++++- .../ChangeEmergencyStateApiControllerTest.php | 144 ++++++++++++++++++ .../ReplaceMotiveControllerTest.php | 6 +- .../tests/Entity/TicketTest.php | 25 +++ .../Normalizer/TicketNormalizerTest.php | 11 ++ 22 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeEmergencyStateCommandHandler.php create mode 100644 src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php create mode 100644 src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php create mode 100644 src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusHistory.php create mode 100644 src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php create mode 100644 src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 899c890b1..eace2f4fa 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -22,7 +22,7 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i - **Backend**: PHP 8.3+, Symfony 5.4 - **Frontend**: JavaScript/TypeScript, Vue.js 3, Bootstrap 5 - **Build Tools**: Webpack Encore, Yarn -- **Database**: PostgreSQL with materialized views +- **Database**: PostgreSQL with materialized views. We do not support other databases. - **Other Services**: Redis, AMQP (RabbitMQ), SMTP ## Project Structure @@ -149,7 +149,35 @@ Key configuration files: - `package.json`: JavaScript dependencies and scripts - `.env`: Default environment variables. Must usually not be updated: use `.env.local` instead. -### Development guidelines +### Database migrations + +Each time a doctrine entity is created, we generate migration to adapt the database. + +The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace `, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command). + +Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok): + +- `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`; +- `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`; +- `Chill\Bundle\CustomFieldsBundle` writes migrations to `Chill\Migrations\CustomFields`; +- `Chill\Bundle\DocGeneratorBundle` writes migrations to `Chill\Migrations\DocGenerator`; +- `Chill\Bundle\DocStoreBundle` writes migrations to `Chill\Migrations\DocStore`; +- `Chill\Bundle\EventBundle` writes migrations to `Chill\Migrations\Event`; +- `Chill\Bundle\CalendarBundle` writes migrations to `Chill\Migrations\Calendar`; +- `Chill\Bundle\FamilyMembersBundle` writes migrations to `Chill\Migrations\FamilyMembers`; +- `Chill\Bundle\FranceTravailApiBundle` writes migrations to `Chill\Migrations\FranceTravailApi`; +- `Chill\Bundle\JobBundle` writes migrations to `Chill\Migrations\Job`; +- `Chill\Bundle\MainBundle` writes migrations to `Chill\Migrations\Main`; +- `Chill\Bundle\PersonBundle` writes migrations to `Chill\Migrations\Person`; +- `Chill\Bundle\ReportBundle` writes migrations to `Chill\Migrations\Report`; +- `Chill\Bundle\TaskBundle` writes migrations to `Chill\Migrations\Task`; +- `Chill\Bundle\ThirdPartyBundle` writes migrations to `Chill\Migrations\ThirdParty`; +- `Chill\Bundle\TicketBundle` writes migrations to `Chill\Migrations\Ticket`; +- `Chill\Bundle\WopiBundle` writes migrations to `Chill\Migrations\Wopi`; + +Once created the, comment's classes should be removed and a description of the changes made to the entities should be added to the migrations, using the `getDescription` method. The migration should not be cleaned by any artificial intelligence, as modifying this migration is error prone. + +### Guidelines related to code structure and requirements #### Usage of clock diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 3aac7f2ec..77bf2797d 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -255,3 +255,36 @@ paths: description: "OK" 401: description: "UNAUTHORIZED" + /1.0/ticket/ticket/{id}/emergency/{emergency}: + post: + tags: + - ticket + summary: Set a ticket as emergency + description: | + Re-open an existing ticket. + + If the ticket is already opened, no action will be performed on this ticket: his state will remains unchanged, and the + ticket will be returned. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + - name: emergency + in: path + required: true + description: the new state of emergency + schema: + type: string + enum: + - yes + - no + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php new file mode 100644 index 000000000..7a72b9910 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php @@ -0,0 +1,24 @@ +newEmergencyStatus === $ticket->getEmergencyStatus()) { + return $ticket; + } + + // End the current emergency status history (if any) + foreach ($ticket->getEmergencyStatusHistories() as $emergencyStatusHistory) { + if (null === $emergencyStatusHistory->getEndDate()) { + $emergencyStatusHistory->setEndDate($this->clock->now()); + } + } + + // Create a new emergency status history with the new status + new EmergencyStatusHistory( + $command->newEmergencyStatus, + $ticket, + $this->clock->now(), + ); + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php index c354294d0..c22f95ee9 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php @@ -12,6 +12,8 @@ declare(strict_types=1); namespace Chill\TicketBundle\Action\Ticket\Handler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; @@ -26,7 +28,9 @@ class CreateTicketCommandHandler $ticket = new Ticket(); $ticket->setExternalRef($command->externalReference); + // initialize the first states new StateHistory(StateEnum::OPEN, $ticket, $this->clock->now()); + new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket, $this->clock->now()); return $ticket; } diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php index 026ab377b..5ad6eed05 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Action\Ticket\Handler; +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\Ticket; @@ -22,6 +23,7 @@ final readonly class ReplaceMotiveCommandHandler public function __construct( private ClockInterface $clock, private EntityManagerInterface $entityManager, + private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, ) {} public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void @@ -50,6 +52,12 @@ final readonly class ReplaceMotiveCommandHandler if ($readyToAdd) { $history = new MotiveHistory($command->motive, $ticket, $this->clock->now()); $this->entityManager->persist($history); + + // Check if the motive has makeTicketEmergency set and update the ticket's emergency status if needed + if ($command->motive->isMakeTicketEmergency()) { + $changeEmergencyCommand = new ChangeEmergencyStateCommand($command->motive->getMakeTicketEmergency()); + ($this->changeEmergencyStateCommandHandler)($ticket, $changeEmergencyCommand); + } } } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php new file mode 100644 index 000000000..1ebc442d6 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php @@ -0,0 +1,73 @@ + '\d+'], methods: ['POST'])] + public function setEmergencyYes(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to set emergency status to YES.'); + } + + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); + $this->changeEmergencyStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } + + #[Route('/api/1.0/ticket/ticket/{id}/emergency/no', name: 'chill_ticket_ticket_emergency_no_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function setEmergencyNo(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to set emergency status to NO.'); + } + + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO); + $this->changeEmergencyStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php new file mode 100644 index 000000000..aa070fbf5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php @@ -0,0 +1,21 @@ + EmergencyStatusHistory::class])] +class EmergencyStatusHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[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::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: false, enumType: EmergencyStatusEnum::class)] + #[Serializer\Groups(['read'])] + private EmergencyStatusEnum $emergencyStatus, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'), + ) { + $ticket->addEmergencyStatusHistory($this); + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmergencyStatus(): EmergencyStatusEnum + { + return $this->emergencyStatus; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setEndDate(?\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php index eceefdf46..8dadecde0 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -33,6 +33,10 @@ class Motive #[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; + public function isActive(): bool { return $this->active; @@ -57,4 +61,19 @@ class Motive { $this->label = $label; } + + public function getMakeTicketEmergency(): ?EmergencyStatusEnum + { + return $this->makeTicketEmergency; + } + + public function setMakeTicketEmergency(?EmergencyStatusEnum $makeTicketEmergency): void + { + $this->makeTicketEmergency = $makeTicketEmergency; + } + + public function isMakeTicketEmergency(): bool + { + return null !== $this->makeTicketEmergency; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index 128c27502..8ef4f64a9 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -90,6 +90,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface #[ORM\OneToMany(targetEntity: StateHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] private Collection $stateHistories; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: EmergencyStatusHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] + private Collection $emergencyStatusHistories; + public function __construct() { $this->addresseeHistory = new ArrayCollection(); @@ -98,6 +104,7 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface $this->personHistories = new ArrayCollection(); $this->inputHistories = new ArrayCollection(); $this->stateHistories = new ArrayCollection(); + $this->emergencyStatusHistories = new ArrayCollection(); } public function getId(): ?int @@ -260,4 +267,31 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface { return $this->stateHistories; } + + /** + * @internal use @see{EmergencyStatusHistory::__construct} instead + */ + public function addEmergencyStatusHistory(EmergencyStatusHistory $emergencyStatusHistory): void + { + $this->emergencyStatusHistories->add($emergencyStatusHistory); + } + + public function getEmergencyStatus(): ?EmergencyStatusEnum + { + foreach ($this->emergencyStatusHistories as $emergencyStatusHistory) { + if (null === $emergencyStatusHistory->getEndDate()) { + return $emergencyStatusHistory->getEmergencyStatus(); + } + } + + return null; + } + + /** + * @return ReadableCollection + */ + public function getEmergencyStatusHistories(): ReadableCollection + { + return $this->emergencyStatusHistories; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index 3615887f7..5f35d9bfe 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -15,6 +15,8 @@ export interface Motive { export type TicketState = "open"|"closed"; +export type TicketEmergencyState = "yes"|"no"; + interface TicketHistory { event_type: T; at: DateTime; @@ -116,6 +118,7 @@ export interface Ticket { createdAt: DateTime | null; updatedBy: User | null; currentState: TicketState | null; + emergency: TicketEmergencyState | null; } export interface addNewPersons { diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index 36e68fae9..87aa5ad05 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\StateHistory; @@ -50,7 +51,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte 'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context), 'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), - 'currentState' => $object->getState()?->value, + 'currentState' => $object->getState()?->value ?? 'open', + 'emergency' => $object->getEmergencyStatus()?->value ?? 'no', ]; } @@ -102,6 +104,17 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ), ...$this->addresseesStates($ticket), ...$this->personStates($ticket), + ...array_map( + fn (EmergencyStatusHistory $stateHistory) => [ + 'event_type' => 'emergency_change', + 'at' => $stateHistory->getStartDate(), + 'by' => $stateHistory->getCreatedBy(), + 'data' => [ + 'new_emergency' => $stateHistory->getEmergencyStatus()->value, + ], + ], + $ticket->getEmergencyStatusHistories()->toArray(), + ), ]; if (null !== $ticket->getCreatedBy() && null !== $ticket->getCreatedAt()) { diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php new file mode 100644 index 000000000..586bdebb4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php @@ -0,0 +1,82 @@ +addSql(<<<'SQL' + CREATE SEQUENCE chill_ticket.emergency_status_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_ticket.emergency_status_history ( + id INT NOT NULL, + ticket_id INT NOT NULL, + endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + emergencyStatus VARCHAR(255) NOT NULL, + startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + createdBy_id INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_16CF4FDB700047D2 ON chill_ticket.emergency_status_history (ticket_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_16CF4FDB3174800F ON chill_ticket.emergency_status_history (createdBy_id) + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.endDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.startDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.createdAt IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT ticket_emergency_state_not_overlaps + exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&) + deferrable initially deferred + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB700047D2 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB3174800F + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_ticket.emergency_status_history + SQL); + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_ticket.emergency_status_history_id_seq + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php new file mode 100644 index 000000000..3022b2b23 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + ALTER TABLE chill_ticket.motive ADD makeTicketEmergency VARCHAR(255) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.motive DROP makeTicketEmergency + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php new file mode 100644 index 000000000..c80204490 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php @@ -0,0 +1,118 @@ +__invoke($ticket, $command); + + // Assert that the ticket is returned unchanged + $this->assertSame($ticket, $result); + $this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + + // Assert that no new emergency status history was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(1, $emergencyStatusHistories); + } + + public function testInvokeWithYesEmergencyStatusToNo(): void + { + $ticket = new Ticket(); + + // Create a YES emergency status history + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket); + + $handler = new ChangeEmergencyStateCommandHandler(new MockClock()); + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket emergency status is now NO + $this->assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus()); + + // Assert that the old emergency status history was ended and a new one was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(2, $emergencyStatusHistories); + + // The first emergency status history should be ended + $yesEmergencyStatusHistory = $emergencyStatusHistories->first(); + $this->assertNotNull($yesEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus()); + + // The last emergency status history should be NO and active + $noEmergencyStatusHistory = $emergencyStatusHistories->last(); + $this->assertNull($noEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus()); + } + + public function testInvokeWithNoEmergencyStatusToYes(): void + { + $ticket = new Ticket(); + + // Create a NO emergency status history + new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket); + + $handler = new ChangeEmergencyStateCommandHandler(new MockClock()); + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket emergency status is now YES + $this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + + // Assert that the old emergency status history was ended and a new one was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(2, $emergencyStatusHistories); + + // The first emergency status history should be ended + $noEmergencyStatusHistory = $emergencyStatusHistories->first(); + $this->assertNotNull($noEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus()); + + // The last emergency status history should be YES and active + $yesEmergencyStatusHistory = $emergencyStatusHistories->last(); + $this->assertNull($yesEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php index 5d4e11566..b58d3f2a2 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php @@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Tests\Action\Ticket\Handler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\Ticket; use PHPUnit\Framework\TestCase; @@ -38,6 +39,7 @@ class CreateTicketCommandHandlerTest extends TestCase self::assertInstanceOf(Ticket::class, $actual); self::assertEquals('', $actual->getExternalRef()); self::assertEquals(StateEnum::OPEN, $actual->getState()); + self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus()); } public function testHandleWithReference(): void @@ -48,5 +50,6 @@ class CreateTicketCommandHandlerTest extends TestCase self::assertInstanceOf(Ticket::class, $actual); self::assertEquals($ref, $actual->getExternalRef()); self::assertEquals(StateEnum::OPEN, $actual->getState()); + self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus()); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php index b0f49dd61..17b1e88d9 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php @@ -11,8 +11,11 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Action\Ticket\Handler; +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\Ticket; @@ -33,10 +36,15 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase private function buildHandler( EntityManagerInterface $entityManager, + ?ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler = null, ): ReplaceMotiveCommandHandler { $clock = new MockClock(); - return new ReplaceMotiveCommandHandler($clock, $entityManager); + if (null === $changeEmergencyStateCommandHandler) { + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class)->reveal(); + } + + return new ReplaceMotiveCommandHandler($clock, $entityManager, $changeEmergencyStateCommandHandler); } public function testHandleOnTicketWithoutMotive(): void @@ -105,4 +113,69 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase self::assertSame($motive, $ticket->getMotive()); self::assertCount(1, $ticket->getMotiveHistories()); } + + public function testHandleUpdatesEmergencyStatusWhenMotiveHasMakeTicketEmergency(): void + { + // Create a motive with makeTicketEmergency set to YES + $motive = new Motive(); + $motive->setMakeTicketEmergency(EmergencyStatusEnum::YES); + + // Create a ticket with no emergency status + $ticket = new Ticket(); + + // Mock the entity manager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); + + // Mock the ChangeEmergencyStateCommandHandler + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus) + )->shouldBeCalled(); + + // Create the handler with our mocks + $handler = $this->buildHandler( + $entityManager->reveal(), + $changeEmergencyStateCommandHandler->reveal() + ); + + // Handle the command + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + // Assert that the motive was set on the ticket + self::assertSame($motive, $ticket->getMotive()); + } + + public function testHandleDoesNotUpdateEmergencyStatusWhenMotiveHasNoMakeTicketEmergency(): void + { + // Create a motive with makeTicketEmergency set to null + $motive = new Motive(); + $motive->setMakeTicketEmergency(null); + + // Create a ticket with no emergency status + $ticket = new Ticket(); + + // Mock the entity manager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); + + // Mock the ChangeEmergencyStateCommandHandler - it should NOT be called + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + Argument::cetera() + )->shouldNotBeCalled(); + + // Create the handler with our mocks + $handler = $this->buildHandler( + $entityManager->reveal(), + $changeEmergencyStateCommandHandler->reveal() + ); + + // Handle the command + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + // Assert that the motive was set on the ticket + self::assertSame($motive, $ticket->getMotive()); + } } diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php new file mode 100644 index 000000000..1a479bc64 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php @@ -0,0 +1,144 @@ +prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->setEmergencyYes($ticket); + } + + public function testSetEmergencyYesWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus) + )->willReturn($ticket)->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setEmergencyYes($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testSetEmergencyNoWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->setEmergencyNo($ticket); + } + + public function testSetEmergencyNoWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::NO === $command->newEmergencyStatus) + )->willReturn($ticket)->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setEmergencyNo($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php index 95c8ec5f4..409cbcf0e 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Controller; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Controller\ReplaceMotiveController; use Chill\TicketBundle\Entity\Motive; @@ -97,9 +98,12 @@ class ReplaceMotiveControllerTest extends KernelTestCase $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); $entityManager->flush()->shouldBeCalled(); + $changeEmergencyCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $handler = new ReplaceMotiveCommandHandler( new MockClock(), - $entityManager->reveal() + $entityManager->reveal(), + $changeEmergencyCommandHandler->reveal(), ); return new ReplaceMotiveController( diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index 5776b3a00..975a8e2e2 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -18,6 +18,8 @@ use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; @@ -133,4 +135,27 @@ class TicketTest extends KernelTestCase self::assertCount(2, $ticket->getStateHistories()); self::assertSame(StateEnum::CLOSED, $ticket->getState()); } + + public function testGetEmergencyStatus(): void + { + $ticket = new Ticket(); + + // Initially, the ticket has no emergency status + self::assertNull($ticket->getEmergencyStatus()); + + // Create an emergency status history entry with the YES status + $history = new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket); + + // Verify that the ticket now has the YES status + self::assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + self::assertCount(1, $ticket->getEmergencyStatusHistories()); + + // Change the emergency status to NO + $history->setEndDate(new \DateTimeImmutable()); + $history2 = new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket); + + // Verify that the ticket now has the NO status + self::assertCount(2, $ticket->getEmergencyStatusHistories()); + self::assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus()); + } } diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index 940c93d97..d30334b0d 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -16,6 +16,8 @@ use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -109,6 +111,7 @@ class TicketNormalizerTest extends KernelTestCase 'currentState' => 'open', 'updatedAt' => $t->getUpdatedAt()->getTimestamp(), 'updatedBy' => ['user'], + 'emergency' => 'no', ], ]; @@ -117,6 +120,7 @@ class TicketNormalizerTest extends KernelTestCase // added by action new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket, new \DateTimeImmutable('2024-06-16T00:00:10Z')); // those are added by doctrine listeners $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); @@ -155,10 +159,12 @@ class TicketNormalizerTest extends KernelTestCase ['event_type' => 'addressees_state'], ['event_type' => 'create_ticket'], ['event_type' => 'state_change'], + ['event_type' => 'emergency_change'], ], 'currentState' => 'open', 'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(), 'updatedBy' => ['user'], + 'emergency' => 'yes', ], ]; } @@ -213,6 +219,11 @@ class TicketNormalizerTest extends KernelTestCase 'json', ['groups' => 'read'] )->will(fn ($args): array => $args[0]); + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_emergency', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => $args[0]); // datetime $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array'))