# Client Portal Phase 3 — Notifications > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service). **Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`. > **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity. **Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript **Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` **Depends on:** Phase 1 + Phase 2 --- ## Chunk 1: Notification Entity & Migration ### Task 1: Create the Notification entity - [ ] **Create `src/Entity/Notification.php`** with the following content: ```php ['notification:read']], denormalizationContext: ['groups' => ['notification:write']], order: ['createdAt' => 'DESC'], )] #[ORM\Entity(repositoryClass: NotificationRepository::class)] #[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] #[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] class Notification { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['notification:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Groups(['notification:read'])] private ?User $user = null; #[ORM\Column(length: 50)] #[Groups(['notification:read'])] private ?string $type = null; #[ORM\Column(length: 255)] #[Groups(['notification:read'])] private ?string $title = null; #[ORM\Column(type: Types::TEXT)] #[Groups(['notification:read'])] private ?string $message = null; #[ORM\ManyToOne(targetEntity: ClientTicket::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Groups(['notification:read'])] private ?ClientTicket $relatedTicket = null; #[ORM\Column] #[Groups(['notification:read', 'notification:write'])] private bool $isRead = false; #[ORM\Column] #[Groups(['notification:read'])] private ?DateTimeImmutable $createdAt = null; public function getId(): ?int { return $this->id; } public function getUser(): ?User { return $this->user; } public function setUser(?User $user): static { $this->user = $user; return $this; } public function getType(): ?string { return $this->type; } public function setType(string $type): static { $this->type = $type; return $this; } public function getTitle(): ?string { return $this->title; } public function setTitle(string $title): static { $this->title = $title; return $this; } public function getMessage(): ?string { return $this->message; } public function setMessage(string $message): static { $this->message = $message; return $this; } public function getRelatedTicket(): ?ClientTicket { return $this->relatedTicket; } public function setRelatedTicket(?ClientTicket $relatedTicket): static { $this->relatedTicket = $relatedTicket; return $this; } public function isRead(): bool { return $this->isRead; } public function setIsRead(bool $isRead): static { $this->isRead = $isRead; return $this; } public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(DateTimeImmutable $createdAt): static { $this->createdAt = $createdAt; return $this; } } ``` ### Task 2: Create the NotificationRepository - [ ] **Create `src/Repository/NotificationRepository.php`**: ```php */ class NotificationRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Notification::class); } public function countUnreadByUser(User $user): int { return (int) $this->createQueryBuilder('n') ->select('COUNT(n.id)') ->where('n.user = :user') ->andWhere('n.isRead = false') ->setParameter('user', $user) ->getQuery() ->getSingleScalarResult(); } public function markAllReadByUser(User $user): int { return $this->createQueryBuilder('n') ->update() ->set('n.isRead', 'true') ->where('n.user = :user') ->andWhere('n.isRead = false') ->setParameter('user', $user) ->getQuery() ->executeStatement(); } } ``` ### Task 3: Generate and run the migration - [ ] **Run inside the PHP container** (`make shell`): ```bash php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate --no-interaction ``` Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`. - [ ] **Commit:** ```bash git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/ git commit -m "feat(notification) : add Notification entity, repository, and migration" ``` --- ## Chunk 2: NotificationProvider & Custom Endpoints ### Task 4: Create the NotificationProvider - [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user: ```php */ final readonly class NotificationProvider implements ProviderInterface { public function __construct( private Security $security, private NotificationRepository $notificationRepository, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object { $user = $this->security->getUser(); return $this->notificationRepository->findBy( ['user' => $user], ['createdAt' => 'DESC'], 30, ); } } ``` - [ ] **Commit:** ```bash git add src/State/NotificationProvider.php git commit -m "feat(notification) : add NotificationProvider filtered by current user" ``` ### Task 5: Create the UnreadCountController - [ ] **Create `src/Controller/NotificationUnreadCountController.php`**: ```php getUser(); $count = $this->notificationRepository->countUnreadByUser($user); return new JsonResponse(['count' => $count]); } } ``` ### Task 6: Create the MarkAllReadController - [ ] **Create `src/Controller/MarkAllReadController.php`**: ```php getUser(); $this->notificationRepository->markAllReadByUser($user); return new Response(null, Response::HTTP_NO_CONTENT); } } ``` - [ ] **Commit:** ```bash git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers" ``` --- ## Chunk 3: NotificationService & Processor Integration ### Task 7: Create NotificationService - [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications: ```php userRepository->findByRole('ROLE_ADMIN'); $number = sprintf('CT-%03d', $ticket->getNumber()); $projectName = $ticket->getProject()?->getName() ?? ''; foreach ($admins as $admin) { $notification = new Notification(); $notification->setUser($admin); $notification->setType('ticket_created'); $notification->setTitle('Nouveau ticket client ' . $number); $notification->setMessage($ticket->getTitle() . ' — ' . $projectName); $notification->setRelatedTicket($ticket); $notification->setCreatedAt(new DateTimeImmutable()); $this->entityManager->persist($notification); } $this->entityManager->flush(); } /** * Notify the ticket submitter that the status has changed. */ public function createForStatusChange(ClientTicket $ticket): void { $submittedBy = $ticket->getSubmittedBy(); if (null === $submittedBy) { return; } $number = sprintf('CT-%03d', $ticket->getNumber()); $statusLabel = $ticket->getStatus(); $message = 'Nouveau statut : ' . $statusLabel; if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) { $message .= ' — ' . $ticket->getStatusComment(); } $notification = new Notification(); $notification->setUser($submittedBy); $notification->setType('ticket_status_changed'); $notification->setTitle('Ticket ' . $number . ' mis à jour'); $notification->setMessage($message); $notification->setRelatedTicket($ticket); $notification->setCreatedAt(new DateTimeImmutable()); $this->entityManager->persist($notification); $this->entityManager->flush(); } } ``` ### Task 8: Add findByRole method to UserRepository - [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`: ```php /** * @return User[] */ public function findByRole(string $role): array { return $this->createQueryBuilder('u') ->where('u.roles LIKE :role') ->setParameter('role', '%"' . $role . '"%') ->getQuery() ->getResult(); } ``` - [ ] **Commit:** ```bash git add src/Service/NotificationService.php src/Repository/UserRepository.php git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole" ``` ### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST) - [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted: Add to constructor parameters: ```php private readonly NotificationService $notificationService, ``` Add import at the top: ```php use App\Service\NotificationService; ``` After `$this->entityManager->flush();` in the POST handling block, add: ```php $this->notificationService->createForTicketCreated($data); ``` ### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH) - [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted: Add to constructor parameters: ```php private readonly NotificationService $notificationService, ``` Add import at the top: ```php use App\Service\NotificationService; ``` After `$this->entityManager->flush();` in the PATCH handling block, add: ```php $this->notificationService->createForStatusChange($data); ``` - [ ] **Commit:** ```bash git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php git commit -m "feat(notification) : hook NotificationService into ticket processors" ``` --- ## Chunk 4: Frontend — DTO & Service ### Task 11: Create the Notification DTO - [ ] **Create `frontend/services/dto/notification.ts`**: ```typescript export type NotificationType = 'ticket_created' | 'ticket_status_changed' export type Notification = { '@id'?: string id: number user: string type: NotificationType title: string message: string relatedTicket: string | null isRead: boolean createdAt: string } ``` ### Task 12: Create the notifications service - [ ] **Create `frontend/services/notifications.ts`**: ```typescript import type { Notification } from './dto/notification' import type { HydraCollection } from '~/utils/api' import { extractHydraMembers } from '~/utils/api' export function useNotificationService() { const api = useApi() async function getAll(): Promise { const data = await api.get>('/notifications') return extractHydraMembers(data) } async function markAsRead(id: number): Promise { await api.patch(`/notifications/${id}`, { isRead: true }, { toast: false, }) } async function markAllAsRead(): Promise { await api.post('/notifications/mark-all-read', {}, { toast: false, }) } async function getUnreadCount(): Promise { const data = await api.get<{ count: number }>('/notifications/unread-count', {}, { toast: false, }) return data.count } return { getAll, markAsRead, markAllAsRead, getUnreadCount } } ``` - [ ] **Commit:** ```bash git add frontend/services/dto/notification.ts frontend/services/notifications.ts git commit -m "feat(frontend) : add notification DTO and service" ``` --- ## Chunk 5: Frontend — Composable & Component ### Task 13: Create the useNotifications composable - [ ] **Create `frontend/composables/useNotifications.ts`**: ```typescript import type { Notification } from '~/services/dto/notification' import { useNotificationService } from '~/services/notifications' const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes export function useNotifications() { const unreadCount = useState('notification-unread-count', () => 0) const notifications = useState('notification-list', () => []) const isLoading = useState('notification-loading', () => false) const service = useNotificationService() let pollTimer: ReturnType | null = null async function fetchUnreadCount(): Promise { try { unreadCount.value = await service.getUnreadCount() } catch { // Silently ignore polling errors } } async function fetchNotifications(): Promise { isLoading.value = true try { notifications.value = await service.getAll() } finally { isLoading.value = false } } async function markAsRead(id: number): Promise { await service.markAsRead(id) const notif = notifications.value.find(n => n.id === id) if (notif && !notif.isRead) { notif.isRead = true unreadCount.value = Math.max(0, unreadCount.value - 1) } } async function markAllAsRead(): Promise { await service.markAllAsRead() notifications.value.forEach(n => n.isRead = true) unreadCount.value = 0 } function startPolling(): void { fetchUnreadCount() pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL) } function stopPolling(): void { if (pollTimer) { clearInterval(pollTimer) pollTimer = null } } return { unreadCount, notifications, isLoading, fetchNotifications, fetchUnreadCount, markAsRead, markAllAsRead, startPolling, stopPolling, } } ``` - [ ] **Commit:** ```bash git add frontend/composables/useNotifications.ts git commit -m "feat(frontend) : add useNotifications composable with polling" ``` ### Task 14: Create the NotificationBell component - [ ] **Create `frontend/components/notification/NotificationBell.vue`**: ```vue ``` - [ ] **Commit:** ```bash git add frontend/components/notification/NotificationBell.vue git commit -m "feat(frontend) : add NotificationBell component with dropdown" ``` --- ## Chunk 6: Layout Integration & i18n ### Task 15: Integrate NotificationBell in AppTopNav - [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `
` block (line 10): Replace: ```vue
``` With: ```vue
``` No imports needed — Nuxt auto-imports components from `frontend/components/`. - [ ] **Commit:** ```bash git add frontend/components/ui/AppTopNav.vue git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar" ``` ### Task 16: Add i18n translations - [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys): ```json "notification": { "title": "Notifications", "markAllRead": "Tout marquer comme lu", "empty": "Aucune notification", "ticketCreated": "Nouveau ticket client {number}", "ticketStatusChanged": "Ticket {number} mis à jour", "timeAgo": { "now": "À l'instant", "minutes": "Il y a {n} min", "hours": "Il y a {n}h", "days": "Il y a {n}j" } } ``` - [ ] **Commit:** ```bash git add frontend/i18n/locales/fr.json git commit -m "feat(i18n) : add notification translations in French" ``` --- ## Chunk 7: Verification & Cleanup ### Task 17: Test backend endpoints manually - [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`): 1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}` 2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination) 3. `GET /api/notifications/unread-count` — should return `{"count": 0}` 4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin 5. `GET /api/notifications` — should now list the `ticket_created` notification 6. `GET /api/notifications/unread-count` — should return `{"count": 1}` 7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read 8. `POST /api/notifications/mark-all-read` — should return 204 ### Task 18: Test frontend notification bell - [ ] **Start dev server** (`make dev-nuxt`) and verify: 1. The bell icon appears in the top navigation bar, to the left of the user avatar 2. Badge shows unread count (or is hidden when 0) 3. Clicking the bell opens a dropdown with notification list 4. Clicking a notification marks it as read and navigates appropriately 5. "Tout marquer comme lu" button works 6. Polling updates the badge every 2 minutes - [ ] **Final commit (if any fixes needed):** ```bash git add -A git commit -m "fix(notification) : polish notification bell and fix edge cases" ```