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
@@ -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;
}