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:
+30
-1
@@ -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
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user