Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/3-3-notification-center-and-bell.md
Saad Zoubir 32e11db2b5 feat: add notification center with bell dropdown, full page, and workspace scoping (Story 3.3)
Enhance NotificationDropdown with type-specific icons, French description builder,
click-to-navigate with mark-as-read, and "Voir toutes les notifications" link.
Add full notifications page at /notifications with pagination (25/page), individual
mark-as-read, and empty state. Includes code review fixes: workspace-scoped unread
count and dropdown items, race condition fix (mark-as-read before navigate),
efficient markAllAsRead via direct update, deleted declaration URL handling,
and per-workspace cache keys. 7 new feature tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:26:40 +01:00

14 KiB

Story 3.3: Notification Center & Bell

Status: review

Story

As a team member, I want to see a notification bell with unread count and access a notification center listing all my notifications, so that I never miss a nudge, alert, or important event and can navigate directly to the relevant declaration.

Acceptance Criteria

  1. When user is logged in with unread notifications:

    • A NotificationBell component in the app sidebar header shows a badge with the unread notification count
    • Clicking the bell opens a dropdown panel showing the 10 most recent notifications
    • Each notification displays: type icon, description text, relative timestamp ("il y a 5 min"), and read/unread visual state
    • Clicking a notification navigates to the relevant declaration detail page and marks the notification as read
    • A "Tout marquer comme lu" action is available in the dropdown header
    • A "Voir toutes les notifications" link at the bottom navigates to /notifications
  2. When user navigates to /notifications:

    • All notifications are listed in reverse chronological order with pagination (25 per page)
    • Each notification shows: type icon, full description, timestamp, and read/unread state
    • Notifications can be marked as read individually or in bulk ("Tout marquer comme lu")
    • Notifications are scoped to the current workspace (via workspace_id in notification data)
  3. When user has zero notifications:

    • An EmptyState is shown: "Aucune notification"
    • The bell badge is hidden (no "0" displayed)

Tasks / Subtasks

  • Task 1: Enhance NotificationDropdown.vue bell component (AC: #1)

    • 1.1 Add type-specific icons per NotificationType (nudge → Send, declaration_overdue → AlertTriangle, document_uploaded → FileUp, status_changed → RefreshCw, bulk_notification → Mail)
    • 1.2 Improve notification item layout: icon + description + relative timestamp + unread blue dot
    • 1.3 Add click handler that navigates to declaration via router.visit() AND calls mark-as-read endpoint
    • 1.4 Add "Voir toutes les notifications" link at dropdown bottom navigating to route('notifications.index')
    • 1.5 Hide badge when unread_count is 0 (do not show "0")
    • 1.6 Ensure dropdown closes after clicking a notification
  • Task 2: Enhance /notifications full page (AC: #2)

    • 2.1 Add type-specific icons matching dropdown icons
    • 2.2 Add click-to-navigate: clicking a notification row visits the declaration and marks it as read
    • 2.3 Add individual "mark as read" action per notification row
    • 2.4 Verify pagination works at 25 per page (already configured in controller)
    • 2.5 Add visual distinction for unread vs read notifications (background color or opacity)
    • 2.6 Format timestamps as relative ("il y a 5 min", "hier") — use created_at which is already diffForHumans() from middleware
  • Task 3: Empty state handling (AC: #3)

    • 3.1 Show "Aucune notification" with Bell icon in dropdown when items array is empty
    • 3.2 Show "Aucune notification pour le moment" on full page when no notifications exist (verify existing empty state)
  • Task 4: Update TypeScript types if needed (AC: #1, #2)

    • 4.1 Ensure NotificationData type covers all notification payloads (declaration_id, sender_id, client_id)
    • 4.2 Add icon mapping utility or computed property for NotificationType → Lucide icon component
  • Task 5: Write feature tests (AC: #1, #2, #3)

    • 5.1 Test: notification dropdown renders with unread count badge
    • 5.2 Test: clicking notification marks it as read (POST to notifications.read)
    • 5.3 Test: mark all as read clears unread count
    • 5.4 Test: notifications page renders with workspace-scoped notifications
    • 5.5 Test: notifications page pagination works at 25 per page
    • 5.6 Test: empty state renders when no notifications exist
    • 5.7 Test: notifications are scoped to current workspace (cross-workspace isolation)

Retrospective Intelligence

  • Team Agreement (Epic 2 Retro — non-negotiable): Load retrospective as context during story creation
  • Pre-existing test failures (A2): 15 failures were resolved in commit 716e9fc before Epic 3 started — baseline is clean
  • Architecture doc drift (A4): due_date not deadline, no Declaration::workspace() scope, mise_en_demeure status undocumented — all corrected in commit 6956f7b
  • withPivot gotcha (Epics 1 & 2): Always chain ->withPivot('role', 'permissions') on workspace relationships — documented in project-context.md
  • D-1 (Deferred from Epic 2): Dashboard "Relancer" dropdown was disabled — resolved in Story 3.2
  • D-2: StatCard assignee param intentional — no action needed
  • D-3: Cache not invalidated on role change — 5-min TTL mitigates, low severity
  • Code review remains mandatory — run after implementation
  • withoutVite() global workaround in Pest.php confirmed compatible — no action needed for tests

Dev Notes

CRITICAL: Existing Infrastructure — DO NOT Recreate

Story 3.3 builds on top of existing components from Stories 3.1 and 3.2. The dev agent MUST enhance, not replace:

Component File Status What to do
NotificationDropdown.vue resources/js/components/NotificationDropdown.vue EXISTS Enhance with type icons, click-to-navigate, "View all" link
notifications/Index.vue resources/js/pages/notifications/Index.vue EXISTS Enhance with type icons, click-to-read, individual mark-as-read
NotificationController app/Http/Controllers/NotificationController.php EXISTS Already has index(), markAsRead(), markAllAsRead() — may need minor updates
Routes routes/web.php EXISTS GET /notifications, POST /notifications/{id}/read, POST /notifications/mark-all-read all registered
Inertia middleware app/Http/Middleware/HandleInertiaRequests.php EXISTS Shares userNotifications prop (unread_count, readUrl, readAllUrl, items via Inertia::defer)
TypeScript types resources/js/types/notification.ts EXISTS AppNotification, NotificationData, NotificationType types defined
NotificationType enum app/Enums/NotificationType.php EXISTS 5 values with French labels
Cache pattern Multiple controllers EXISTS Cache::forget("user:{$userId}:unread_notifications")

Inertia Shared Props (Already Wired)

The HandleInertiaRequests.php middleware already shares on every page:

'userNotifications' => [
    'unread_count' => Cache::remember("user:{$user->id}:unread_notifications", 60, ...),
    'readUrl' => route('notifications.read', ['id' => '__ID__']),
    'readAllUrl' => route('notifications.readAll'),
    'items' => Inertia::defer(fn () => [...10 most recent...])
]

The readUrl uses __ID__ placeholder — replace in frontend with actual notification ID when calling.

Icon Mapping for Notification Types

Use lucide-vue-next icons (already installed):

NotificationType Icon Import
nudge Send import { Send } from 'lucide-vue-next'
declaration_overdue AlertTriangle import { AlertTriangle } from 'lucide-vue-next'
document_uploaded FileUp import { FileUp } from 'lucide-vue-next'
bulk_notification Mail import { Mail } from 'lucide-vue-next'
status_changed RefreshCw import { RefreshCw } from 'lucide-vue-next'

Click-to-Navigate Pattern

When a notification is clicked, two things must happen:

  1. Mark as read: POST to readUrl.replace('__ID__', notification.id) — use router.post() from @inertiajs/vue3
  2. Navigate to declaration: router.visit(route('declarations.show', notification.data.declaration_id))

Handle both in sequence — mark as read first, then navigate. Or combine: the mark-as-read endpoint can redirect to the declaration. Check current NotificationController@markAsRead implementation.

Workspace Scoping

All notification queries are already workspace-scoped via:

->whereJsonContains('data->workspace_id', $workspace->id)

This is implemented in NotificationController@index and HandleInertiaRequests middleware. No additional scoping needed in frontend — data arrives pre-filtered.

Description Text Generation

Build notification description from notification.data and notification.type:

  • NudgeNotification → "Relance de {sender_name} sur {declaration_title}"
  • DeclarationOverdueNotification → "Déclaration en retard : {declaration_title}"
  • DocumentUploadedNotification → "Document téléversé pour {declaration_title}"
  • StatusChangedNotification → "Statut modifié : {declaration_title}"

Note: The existing NotificationDropdown.vue already has some description logic — extend it, don't replace.

Testing Standards

  • Framework: Pest PHP syntax with RefreshDatabase
  • URL generation: Always use route() helper, never hardcode URLs
  • Auth setup: Use $this->actingAs($user) with workspace session set
  • Notification factory: Use $user->notify(new NudgeNotification($declaration, $sender)) to seed test data
  • Assertions: assertInertia() for page rendering, check notification props
  • Test file: tests/Feature/Notifications/NotificationCenterTest.php (new file)
  • Baseline: 247 tests passing — zero regressions allowed

Established Code Patterns (from Stories 3.1 & 3.2)

Controller authorization:

$workspace = $this->currentWorkspace();
// Use HasWorkspaceScope trait
$this->authorizeWorkspaceAccess($model); // aborts 404 if wrong workspace

Cache invalidation after state change:

Cache::forget("user:{$userId}:unread_notifications");

Flash messages:

return back()->with('flash', ['type' => 'success', 'message' => '...']);

Vue component pattern:

<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import type { AppNotification } from '@/types/notification';
// Always <script setup lang="ts">, never Options API
</script>

Project Structure Notes

  • Frontend components: resources/js/components/ for reusable, resources/js/pages/ for route pages
  • Sidebar header: resources/js/components/AppSidebarHeader.vue — where NotificationDropdown is mounted
  • UI primitives: resources/js/components/ui/ — shadcn-vue components (popover, button, dropdown, etc.)
  • Type definitions: resources/js/types/ — TypeScript types with import type for type-only imports
  • Inertia render paths use lowercase subdirectories: 'notifications/Index'

References

  • [Source: _bmad-output/planning-artifacts/epics.md — Epic 3, Story 3.3]
  • [Source: _bmad-output/planning-artifacts/architecture.md — D8: In-App Notification System, D10: Real-Time Features]
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md — Notification Bell, Notification Patterns]
  • [Source: _bmad-output/planning-artifacts/prd.md — FR30, FR31]
  • [Source: _bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md]
  • [Source: _bmad-output/implementation-artifacts/3-2-one-click-nudge-system.md]
  • [Source: _bmad-output/implementation-artifacts/epic-2-retro-2026-03-24.md — D-1, Team Agreements]
  • [Source: _bmad-output/project-context.md — Gotchas, Tech Stack]

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (1M context)

Debug Log References

  • Initial NudgeNotification tests failed due to mail channel sending real emails — fixed by adding Mail::fake() to test setup
  • Pagination links type in Index.vue needed proper typing for url, label, active fields
  • Used notifications.through() for controller data enrichment to work with Laravel's paginator

Completion Notes List

  • Task 4 (Types): Extended NotificationData type with declaration_title, sender_name, url fields for enriched display data
  • Backend enrichment: Added notification data enrichment in both HandleInertiaRequests middleware (dropdown) and NotificationController@index (full page) — batch-loads declaration titles and sender names via 2 queries to avoid N+1
  • Task 1 (Dropdown): Rewrote NotificationDropdown.vue with type-specific Lucide icons per NotificationType, French description text builder, click-to-navigate with mark-as-read, "Tout marquer comme lu" in header, "Voir toutes les notifications" link, badge hidden when count is 0
  • Task 2 (Full page): Enhanced notifications/Index.vue with type icons, clickable rows that navigate + mark-as-read, individual "Marquer lu" button per row, unread/read visual distinction (background + opacity), proper pagination controls
  • Task 3 (Empty states): Dropdown shows Bell icon + "Aucune notification" when empty; full page shows Bell icon + "Aucune notification pour le moment" (preserved existing pattern)
  • Task 5 (Tests): Created 7 feature tests in NotificationCenterTest.php covering all ACs — dropdown shared props, mark-as-read, mark-all-read, full page rendering, pagination at 25/page, empty state, cross-workspace isolation
  • Regression check: 254 tests pass (up from 247 baseline + 7 new), zero failures

Change Log

  • 2026-03-26: Implemented Story 3.3 — Notification Center & Bell enhancements (all 5 tasks, 7 new tests)

File List

  • resources/js/types/notification.ts — Modified: added declaration_title, sender_name, url to NotificationData
  • resources/js/components/NotificationDropdown.vue — Modified: type icons, description builder, click-navigate, "View all" link, empty state
  • resources/js/pages/notifications/Index.vue — Modified: type icons, click-navigate, individual mark-read, pagination, visual distinction
  • app/Http/Middleware/HandleInertiaRequests.php — Modified: enriched notification items with declaration titles/sender names/URLs, added notificationsUrl shared prop
  • app/Http/Controllers/NotificationController.php — Modified: enriched notification data in index(), added readUrl prop
  • tests/Feature/Notifications/NotificationCenterTest.php — New: 7 feature tests for notification center