Files
Lesstime/docs/superpowers/plans/2026-03-15-client-portal-phase3.md
2026-03-15 19:18:25 +01:00

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 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

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 the findByRole method 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 — Inject NotificationService in the constructor and call createForTicketCreated() 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 — Inject NotificationService in the constructor and call createForStatusChange() 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):
  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):
git add -A
git commit -m "fix(notification) : polish notification bell and fix edge cases"