971 lines
28 KiB
Markdown
971 lines
28 KiB
Markdown
# 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"
|
|
```
|