From 0cce586a1f5a0df5bab1c2f5695d1ce16cb9b983 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sun, 21 Jun 2026 01:15:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(client-portal)=20:=20phase=203=20=E2=80=94?= =?UTF-8?q?=20ticket=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LST-69 (3.2) phase 3. Wires the existing notification system to client-ticket events (the bell/useNotifications/endpoints already existed). - Notification.relatedTicket (ManyToOne ClientTicketInterface, SET NULL) + additive migration + notification:read group. - NotifierInterface::notify() gains a backward-compatible optional relatedTicket param (existing callers unchanged). - ClientTicketNumberProcessor (POST): notifies all ROLE_ADMIN users (ticket_created), tolerant try/catch after flush. ClientTicketStatusProcessor (PATCH): notifies submittedBy on status change (ticket_status_changed). - Front: notification DTO relatedTicket; NotificationBell navigates to /admin (admin) or /portal (client) on ticket notifications. 180 tests green (178 + 2), nuxt build passes, cs-fixer clean. --- .../notification/NotificationBell.vue | 11 +++ frontend/services/dto/notification.ts | 1 + migrations/Version20260621130000.php | 41 ++++++++++ .../State/ClientTicketNumberProcessor.php | 31 ++++++- .../State/ClientTicketStatusProcessor.php | 30 +++++++ .../Core/Domain/Entity/Notification.php | 20 +++++ src/Module/Core/Infrastructure/Notifier.php | 11 ++- .../Domain/Contract/NotifierInterface.php | 8 +- .../ClientPortal/ClientTicketApiTest.php | 81 +++++++++++++++++++ 9 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 migrations/Version20260621130000.php diff --git a/frontend/components/notification/NotificationBell.vue b/frontend/components/notification/NotificationBell.vue index 38383e9..e3e9b6d 100644 --- a/frontend/components/notification/NotificationBell.vue +++ b/frontend/components/notification/NotificationBell.vue @@ -102,11 +102,22 @@ function toggleDropdown() { } } +const auth = useAuthStore() + +const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN')) + function handleClick(notif: Notification) { if (!notif.isRead) { markAsRead(notif.id) } isOpen.value = false + + // Deep-link to the related ticket when present. The notification payload does + // not carry the ticket's project, so we route to the relevant list view: + // admins to the client-tickets admin tab, clients to their portal. + if (notif.relatedTicket) { + navigateTo(isAdmin.value ? '/admin' : '/portal') + } } async function handleMarkAllRead() { diff --git a/frontend/services/dto/notification.ts b/frontend/services/dto/notification.ts index 9b8fe43..4c8e293 100644 --- a/frontend/services/dto/notification.ts +++ b/frontend/services/dto/notification.ts @@ -7,6 +7,7 @@ export type Notification = { type: NotificationType title: string message: string + relatedTicket: string | null isRead: boolean createdAt: string } diff --git a/migrations/Version20260621130000.php b/migrations/Version20260621130000.php new file mode 100644 index 0000000..cb565c4 --- /dev/null +++ b/migrations/Version20260621130000.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE notification ADD related_ticket_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA98F144DB FOREIGN KEY (related_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE INDEX idx_notification_related_ticket ON notification (related_ticket_id)'); + $this->addSql("COMMENT ON COLUMN notification.related_ticket_id IS 'Ticket client lie a la notification (FK client_ticket, SET NULL). null = notification non liee a un ticket.'"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA98F144DB'); + $this->addSql('DROP INDEX idx_notification_related_ticket'); + $this->addSql('ALTER TABLE notification DROP related_ticket_id'); + } +} diff --git a/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketNumberProcessor.php b/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketNumberProcessor.php index 91ca856..44b5c47 100644 --- a/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketNumberProcessor.php +++ b/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketNumberProcessor.php @@ -9,6 +9,8 @@ use ApiPlatform\State\ProcessorInterface; use App\Module\ClientPortal\Domain\Entity\ClientTicket; use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus; use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface; +use App\Module\Core\Domain\Repository\UserRepositoryInterface; +use App\Shared\Domain\Contract\NotifierInterface; use App\Shared\Domain\Contract\ProjectInterface; use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; @@ -17,6 +19,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Throwable; /** * Handles creation of a client ticket (POST). @@ -41,6 +44,8 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface private ClientTicketRepositoryInterface $repository, private EntityManagerInterface $entityManager, private Security $security, + private NotifierInterface $notifier, + private UserRepositoryInterface $userRepository, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket @@ -73,7 +78,7 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface $data->setCreatedAt($now); $data->setUpdatedAt($now); - return $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket { + $ticket = $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket { $maxNumber = $this->repository->findMaxNumberByProjectForUpdate((int) $project->getId()); $data->setNumber($maxNumber + 1); @@ -82,6 +87,30 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface return $result; }); + + // Notify admins after the ticket is committed; never let a notification + // failure roll back or break the creation. + $this->notifyAdmins($ticket, $project); + + return $ticket; + } + + private function notifyAdmins(ClientTicket $ticket, ProjectInterface $project): void + { + try { + $number = sprintf('CT-%03d', (int) $ticket->getNumber()); + $projectName = $project->getName() ?? ''; + $title = sprintf('Nouveau ticket client %s', $number); + $message = '' !== $projectName + ? sprintf('« %s » — %s', (string) $ticket->getTitle(), $projectName) + : sprintf('« %s »', (string) $ticket->getTitle()); + + foreach ($this->userRepository->findByRole('ROLE_ADMIN') as $admin) { + $this->notifier->notify($admin, 'ticket_created', $title, $message, $ticket); + } + } catch (Throwable) { + // Tolerant: ticket creation must succeed even if notifications fail. + } } private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool diff --git a/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketStatusProcessor.php b/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketStatusProcessor.php index 508ffed..64a78de 100644 --- a/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketStatusProcessor.php +++ b/src/Module/ClientPortal/Infrastructure/ApiPlatform/State/ClientTicketStatusProcessor.php @@ -8,9 +8,12 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Module\ClientPortal\Domain\Entity\ClientTicket; use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus; +use App\Shared\Domain\Contract\NotifierInterface; +use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Throwable; /** * Handles status changes on a client ticket (PATCH, ROLE_ADMIN only). @@ -25,6 +28,7 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface { public function __construct( private EntityManagerInterface $entityManager, + private NotifierInterface $notifier, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket @@ -53,6 +57,32 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface $this->entityManager->persist($data); $this->entityManager->flush(); + // Notify the submitter when the status actually changed. + if ($oldStatus !== $newStatus) { + $this->notifySubmitter($data, $newStatus); + } + return $data; } + + private function notifySubmitter(ClientTicket $ticket, ClientTicketStatus $newStatus): void + { + $submitter = $ticket->getSubmittedBy(); + if (!$submitter instanceof UserInterface) { + return; + } + + try { + $number = sprintf('CT-%03d', (int) $ticket->getNumber()); + $title = sprintf('Ticket %s mis à jour', $number); + $comment = trim((string) $ticket->getStatusComment()); + $message = '' !== $comment + ? sprintf('%s — %s', $newStatus->label(), $comment) + : $newStatus->label(); + + $this->notifier->notify($submitter, 'ticket_status_changed', $title, $message, $ticket); + } catch (Throwable) { + // Tolerant: the status change must succeed even if notifications fail. + } + } } diff --git a/src/Module/Core/Domain/Entity/Notification.php b/src/Module/Core/Domain/Entity/Notification.php index 1eded34..41756ff 100644 --- a/src/Module/Core/Domain/Entity/Notification.php +++ b/src/Module/Core/Domain/Entity/Notification.php @@ -9,6 +9,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use App\Module\Core\Infrastructure\ApiPlatform\State\NotificationProvider; use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository; +use App\Shared\Domain\Contract\ClientTicketInterface; use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; @@ -32,6 +33,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Entity(repositoryClass: DoctrineNotificationRepository::class)] #[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] #[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] +#[ORM\Index(columns: ['related_ticket_id'], name: 'idx_notification_related_ticket')] class Notification { #[ORM\Id] @@ -57,6 +59,12 @@ class Notification #[Groups(['notification:read'])] private ?string $message = null; + /** Optional link to the client ticket this notification relates to. */ + #[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)] + #[ORM\JoinColumn(name: 'related_ticket_id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['notification:read'])] + private ?ClientTicketInterface $relatedTicket = null; + #[ORM\Column] #[Groups(['notification:read', 'notification:write'])] private bool $isRead = false; @@ -118,6 +126,18 @@ class Notification return $this; } + public function getRelatedTicket(): ?ClientTicketInterface + { + return $this->relatedTicket; + } + + public function setRelatedTicket(?ClientTicketInterface $relatedTicket): static + { + $this->relatedTicket = $relatedTicket; + + return $this; + } + public function isRead(): bool { return $this->isRead; diff --git a/src/Module/Core/Infrastructure/Notifier.php b/src/Module/Core/Infrastructure/Notifier.php index 8be13e6..38a1cbf 100644 --- a/src/Module/Core/Infrastructure/Notifier.php +++ b/src/Module/Core/Infrastructure/Notifier.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Core\Infrastructure; use App\Module\Core\Domain\Entity\Notification; +use App\Shared\Domain\Contract\ClientTicketInterface; use App\Shared\Domain\Contract\NotifierInterface; use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; @@ -14,13 +15,19 @@ final readonly class Notifier implements NotifierInterface { public function __construct(private EntityManagerInterface $em) {} - public function notify(UserInterface $user, string $type, string $title, string $message): void - { + public function notify( + UserInterface $user, + string $type, + string $title, + string $message, + ?ClientTicketInterface $relatedTicket = null, + ): void { $notification = new Notification(); $notification->setUser($user); $notification->setType($type); $notification->setTitle($title); $notification->setMessage($message); + $notification->setRelatedTicket($relatedTicket); $notification->setCreatedAt(new DateTimeImmutable()); $this->em->persist($notification); diff --git a/src/Shared/Domain/Contract/NotifierInterface.php b/src/Shared/Domain/Contract/NotifierInterface.php index 59872b2..ec6225e 100644 --- a/src/Shared/Domain/Contract/NotifierInterface.php +++ b/src/Shared/Domain/Contract/NotifierInterface.php @@ -6,5 +6,11 @@ namespace App\Shared\Domain\Contract; interface NotifierInterface { - public function notify(UserInterface $user, string $type, string $title, string $message): void; + public function notify( + UserInterface $user, + string $type, + string $title, + string $message, + ?ClientTicketInterface $relatedTicket = null, + ): void; } diff --git a/tests/Functional/Module/ClientPortal/ClientTicketApiTest.php b/tests/Functional/Module/ClientPortal/ClientTicketApiTest.php index 7986abd..12f08d3 100644 --- a/tests/Functional/Module/ClientPortal/ClientTicketApiTest.php +++ b/tests/Functional/Module/ClientPortal/ClientTicketApiTest.php @@ -6,8 +6,11 @@ namespace App\Tests\Functional\Module\ClientPortal; use App\Module\ClientPortal\Domain\Entity\ClientTicket; use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus; +use App\Module\ClientPortal\Domain\Enum\ClientTicketType; +use App\Module\Core\Domain\Entity\Notification; use App\Module\Core\Domain\Entity\User; use App\Module\ProjectManagement\Domain\Entity\Project; +use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -123,6 +126,84 @@ final class ClientTicketApiTest extends WebTestCase self::assertGreaterThanOrEqual(1, $data['number']); } + public function testCreatingTicketNotifiesAdmins(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $projectIri = $this->sirhProjectIri($em); + $this->loginClient($client, 'client-liot'); + + $title = 'Notif test '.uniqid('', true); + $client->request('POST', '/api/client_tickets', server: [ + 'CONTENT_TYPE' => 'application/ld+json', + ], content: json_encode([ + 'type' => 'other', + 'title' => $title, + 'description' => 'Trigger an admin notification.', + 'project' => $projectIri, + ])); + + self::assertResponseStatusCodeSame(201); + $data = json_decode($client->getResponse()->getContent(), true); + $ticketId = $data['id']; + + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertNotNull($admin); + + $notification = $em->getRepository(Notification::class)->findOneBy([ + 'user' => $admin, + 'type' => 'ticket_created', + ], ['createdAt' => 'DESC']); + + self::assertInstanceOf(Notification::class, $notification); + self::assertNotNull($notification->getRelatedTicket()); + self::assertSame($ticketId, $notification->getRelatedTicket()->getId()); + } + + public function testChangingStatusNotifiesSubmitter(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + // Set up a fresh `new` ticket whose submitter is a known client user, + // so we can assert the submitter is the notification recipient. + $submitter = $em->getRepository(User::class)->findOneBy(['username' => 'client-liot']); + self::assertNotNull($submitter); + $project = $em->getRepository(Project::class)->findOneBy(['code' => 'SIRH']); + self::assertNotNull($project); + + $ticket = new ClientTicket(); + $ticket->setType(ClientTicketType::Other); + $ticket->setTitle('Status notif '.uniqid('', true)); + $ticket->setDescription('Will be moved to in_progress.'); + $ticket->setProject($project); + $ticket->setSubmittedBy($submitter); + $ticket->setStatus(ClientTicketStatus::New); + $ticket->setNumber(9000 + random_int(1, 999)); + $ticket->setCreatedAt(new DateTimeImmutable()); + $ticket->setUpdatedAt(new DateTimeImmutable()); + $em->persist($ticket); + $em->flush(); + $ticketId = $ticket->getId(); + + // Admin moves it to in_progress. + $this->loginClient($client, 'admin'); + $client->request('PATCH', '/api/client_tickets/'.$ticketId, server: [ + 'CONTENT_TYPE' => 'application/merge-patch+json', + ], content: json_encode(['status' => 'in_progress'])); + self::assertResponseIsSuccessful(); + + $notification = $em->getRepository(Notification::class)->findOneBy([ + 'user' => $submitter, + 'type' => 'ticket_status_changed', + ], ['createdAt' => 'DESC']); + + self::assertInstanceOf(Notification::class, $notification); + self::assertNotNull($notification->getRelatedTicket()); + self::assertSame($ticketId, $notification->getRelatedTicket()->getId()); + } + public function testAdminCannotCreateTicket(): void { $client = self::createClient();