feat(client-portal) : phase 3 — ticket notifications

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.
This commit is contained in:
Matthieu
2026-06-21 01:15:05 +02:00
parent 144a8a4685
commit 0cce586a1f
9 changed files with 230 additions and 4 deletions
@@ -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() {
+1
View File
@@ -7,6 +7,7 @@ export type Notification = {
type: NotificationType
title: string
message: string
relatedTicket: string | null
isRead: boolean
createdAt: string
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Client portal — phase 3: link notifications to client tickets (additive).
*
* - Adds notification.related_ticket_id (FK client_ticket, SET NULL) plus its index,
* so a notification can deep-link to the ticket it concerns without breaking the
* existing task-based notifications (column is nullable).
*
* Lowercase SQL columns; FK/index names mirror Doctrine's generated identifiers.
* down() reverses.
*/
final class Version20260621130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Client portal phase 3: notification.related_ticket_id link to client_ticket (additive)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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
@@ -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.
}
}
}
@@ -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;
+9 -2
View File
@@ -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);
@@ -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;
}
@@ -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();