docs : add client portal implementation plans (phases 1-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:18:25 +01:00
parent 5547c67b30
commit f4eec2e6e9
3 changed files with 4515 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,970 @@
# 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
<?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
<?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`):
```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
<?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:**
```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
<?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
<?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:**
```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
<?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 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<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:**
```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<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:**
```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
<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:**
```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 `<div class="ml-auto flex gap-4 ...">` block (line 10):
Replace:
```vue
<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:
```vue
<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:**
```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"
```