28 KiB
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
NotificationServiceinjected into existing processors (ClientTicketNumberProcessorandClientTicketStatusProcessor), rather than a separateClientTicketNotificationProcessor. 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.phpwith the following content:
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
provider: NotificationProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Patch(
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
),
],
normalizationContext: ['groups' => ['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
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
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):
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:
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
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<Notification>
*/
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:
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
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $count]);
}
}
Task 6: Create the MarkAllReadController
- Create
src/Controller/MarkAllReadController.php:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
return new Response(null, Response::HTTP_NO_CONTENT);
}
}
- Commit:
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
declare(strict_types=1);
namespace App\Service;
use App\Entity\ClientTicket;
use App\Entity\Notification;
use App\Entity\User;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
final readonly class NotificationService
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserRepository $userRepository,
) {}
/**
* Notify all ROLE_ADMIN users that a new ticket was created.
*/
public function createForTicketCreated(ClientTicket $ticket): void
{
$admins = $this->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 thefindByRolemethod at the end of the class, before the closing}:
/**
* @return User[]
*/
public function findByRole(string $role): array
{
return $this->createQueryBuilder('u')
->where('u.roles LIKE :role')
->setParameter('role', '%"' . $role . '"%')
->getQuery()
->getResult();
}
- Commit:
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— InjectNotificationServicein the constructor and callcreateForTicketCreated()after the ticket is persisted:
Add to constructor parameters:
private readonly NotificationService $notificationService,
Add import at the top:
use App\Service\NotificationService;
After $this->entityManager->flush(); in the POST handling block, add:
$this->notificationService->createForTicketCreated($data);
Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH)
- Modify
src/State/ClientTicketStatusProcessor.php— InjectNotificationServicein the constructor and callcreateForStatusChange()after the status update is persisted:
Add to constructor parameters:
private readonly NotificationService $notificationService,
Add import at the top:
use App\Service\NotificationService;
After $this->entityManager->flush(); in the PATCH handling block, add:
$this->notificationService->createForStatusChange($data);
- Commit:
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:
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:
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<Notification[]> {
const data = await api.get<HydraCollection<Notification>>('/notifications')
return extractHydraMembers(data)
}
async function markAsRead(id: number): Promise<void> {
await api.patch(`/notifications/${id}`, { isRead: true }, {
toast: false,
})
}
async function markAllAsRead(): Promise<void> {
await api.post('/notifications/mark-all-read', {}, {
toast: false,
})
}
async function getUnreadCount(): Promise<number> {
const data = await api.get<{ count: number }>('/notifications/unread-count', {}, {
toast: false,
})
return data.count
}
return { getAll, markAsRead, markAllAsRead, getUnreadCount }
}
- Commit:
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:
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<number>('notification-unread-count', () => 0)
const notifications = useState<Notification[]>('notification-list', () => [])
const isLoading = useState<boolean>('notification-loading', () => false)
const service = useNotificationService()
let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchUnreadCount(): Promise<void> {
try {
unreadCount.value = await service.getUnreadCount()
} catch {
// Silently ignore polling errors
}
}
async function fetchNotifications(): Promise<void> {
isLoading.value = true
try {
notifications.value = await service.getAll()
} finally {
isLoading.value = false
}
}
async function markAsRead(id: number): Promise<void> {
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<void> {
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:
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:
<template>
<div ref="bellRef" class="relative">
<button
type="button"
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
@click="toggleDropdown"
>
<Icon name="mdi:bell-outline" size="24" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<Transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<h3 class="text-sm font-semibold text-neutral-800">
{{ $t('notification.title') }}
</h3>
<button
v-if="unreadCount > 0"
type="button"
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
@click="handleMarkAllRead"
>
{{ $t('notification.markAllRead') }}
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
</div>
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
{{ $t('notification.empty') }}
</div>
<template v-else>
<button
v-for="notif in notifications"
:key="notif.id"
type="button"
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
:class="{ 'bg-primary-50': !notif.isRead }"
@click="handleClick(notif)"
>
<div
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-neutral-800 truncate">
{{ notif.title }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ notif.message }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ formatRelativeDate(notif.createdAt) }}
</p>
</div>
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Notification } from '~/services/dto/notification'
import { useNotifications } from '~/composables/useNotifications'
const {
unreadCount,
notifications,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
startPolling,
stopPolling,
} = useNotifications()
const bellRef = ref<HTMLElement>()
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
fetchNotifications()
}
}
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
if (notif.relatedTicket) {
const ticketId = notif.relatedTicket.split('/').pop()
const auth = useAuthStore()
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
if (isClient) {
navigateTo(`/portal`)
} else {
navigateTo(`/admin?tab=tickets`)
}
isOpen.value = false
}
}
async function handleMarkAllRead() {
await markAllAsRead()
}
const { t } = useI18n()
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMin / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMin < 1) return t('notification.timeAgo.now')
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
// Close dropdown when clicking outside
function onClickOutside(event: MouseEvent) {
if (!bellRef.value?.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
startPolling()
document.addEventListener('click', onClickOutside)
})
onUnmounted(() => {
stopPolling()
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
- Commit:
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<div class="ml-auto flex gap-4 ...">block (line 10):
Replace:
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
<div class="group relative flex gap-2 sm:gap-4">
With:
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
No imports needed — Nuxt auto-imports components from frontend/components/.
- Commit:
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):
"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:
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):
- Log in at
POST /login_checkwith{"username":"admin","password":"admin"} GET /api/notifications— should return empty hydra collection (latest 30, no pagination)GET /api/notifications/unread-count— should return{"count": 0}- Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin
GET /api/notifications— should now list theticket_creatednotificationGET /api/notifications/unread-count— should return{"count": 1}PATCH /api/notifications/{id}with{"isRead": true}— should mark notification as readPOST /api/notifications/mark-all-read— should return 204
Task 18: Test frontend notification bell
- Start dev server (
make dev-nuxt) and verify:
- The bell icon appears in the top navigation bar, to the left of the user avatar
- Badge shows unread count (or is hidden when 0)
- Clicking the bell opens a dropdown with notification list
- Clicking a notification marks it as read and navigates appropriately
- "Tout marquer comme lu" button works
- Polling updates the badge every 2 minutes
- Final commit (if any fixes needed):
git add -A
git commit -m "fix(notification) : polish notification bell and fix edge cases"