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>
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
-
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
-
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_idin notification data)
-
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.vuebell 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
- 1.1 Add type-specific icons per
-
Task 2: Enhance
/notificationsfull 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_atwhich is alreadydiffForHumans()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
NotificationDatatype covers all notification payloads (declaration_id, sender_id, client_id) - 4.2 Add icon mapping utility or computed property for
NotificationType→ Lucide icon component
- 4.1 Ensure
-
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
716e9fcbefore Epic 3 started — baseline is clean - Architecture doc drift (A4):
due_datenotdeadline, noDeclaration::workspace()scope,mise_en_demeurestatus undocumented — all corrected in commit6956f7b - 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
assigneeparam 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:
- Mark as read: POST to
readUrl.replace('__ID__', notification.id)— userouter.post()from@inertiajs/vue3 - 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— whereNotificationDropdownis mounted - UI primitives:
resources/js/components/ui/— shadcn-vue components (popover, button, dropdown, etc.) - Type definitions:
resources/js/types/— TypeScript types withimport typefor 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,activefields - Used
notifications.through()for controller data enrichment to work with Laravel's paginator
Completion Notes List
- Task 4 (Types): Extended
NotificationDatatype withdeclaration_title,sender_name,urlfields for enriched display data - Backend enrichment: Added notification data enrichment in both
HandleInertiaRequestsmiddleware (dropdown) andNotificationController@index(full page) — batch-loads declaration titles and sender names via 2 queries to avoid N+1 - Task 1 (Dropdown): Rewrote
NotificationDropdown.vuewith 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.vuewith 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.phpcovering 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: addeddeclaration_title,sender_name,urltoNotificationDataresources/js/components/NotificationDropdown.vue— Modified: type icons, description builder, click-navigate, "View all" link, empty stateresources/js/pages/notifications/Index.vue— Modified: type icons, click-navigate, individual mark-read, pagination, visual distinctionapp/Http/Middleware/HandleInertiaRequests.php— Modified: enriched notification items with declaration titles/sender names/URLs, addednotificationsUrlshared propapp/Http/Controllers/NotificationController.php— Modified: enriched notification data in index(), addedreadUrlproptests/Feature/Notifications/NotificationCenterTest.php— New: 7 feature tests for notification center