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>
240 lines
14 KiB
Markdown
240 lines
14 KiB
Markdown
# 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
|
|
|
|
- [x] Task 1: Enhance `NotificationDropdown.vue` bell component (AC: #1)
|
|
- [x] 1.1 Add type-specific icons per `NotificationType` (nudge → `Send`, declaration_overdue → `AlertTriangle`, document_uploaded → `FileUp`, status_changed → `RefreshCw`, bulk_notification → `Mail`)
|
|
- [x] 1.2 Improve notification item layout: icon + description + relative timestamp + unread blue dot
|
|
- [x] 1.3 Add click handler that navigates to declaration via `router.visit()` AND calls mark-as-read endpoint
|
|
- [x] 1.4 Add "Voir toutes les notifications" link at dropdown bottom navigating to `route('notifications.index')`
|
|
- [x] 1.5 Hide badge when unread_count is 0 (do not show "0")
|
|
- [x] 1.6 Ensure dropdown closes after clicking a notification
|
|
|
|
- [x] Task 2: Enhance `/notifications` full page (AC: #2)
|
|
- [x] 2.1 Add type-specific icons matching dropdown icons
|
|
- [x] 2.2 Add click-to-navigate: clicking a notification row visits the declaration and marks it as read
|
|
- [x] 2.3 Add individual "mark as read" action per notification row
|
|
- [x] 2.4 Verify pagination works at 25 per page (already configured in controller)
|
|
- [x] 2.5 Add visual distinction for unread vs read notifications (background color or opacity)
|
|
- [x] 2.6 Format timestamps as relative ("il y a 5 min", "hier") — use `created_at` which is already `diffForHumans()` from middleware
|
|
|
|
- [x] Task 3: Empty state handling (AC: #3)
|
|
- [x] 3.1 Show "Aucune notification" with Bell icon in dropdown when items array is empty
|
|
- [x] 3.2 Show "Aucune notification pour le moment" on full page when no notifications exist (verify existing empty state)
|
|
|
|
- [x] Task 4: Update TypeScript types if needed (AC: #1, #2)
|
|
- [x] 4.1 Ensure `NotificationData` type covers all notification payloads (declaration_id, sender_id, client_id)
|
|
- [x] 4.2 Add icon mapping utility or computed property for `NotificationType` → Lucide icon component
|
|
|
|
- [x] Task 5: Write feature tests (AC: #1, #2, #3)
|
|
- [x] 5.1 Test: notification dropdown renders with unread count badge
|
|
- [x] 5.2 Test: clicking notification marks it as read (POST to `notifications.read`)
|
|
- [x] 5.3 Test: mark all as read clears unread count
|
|
- [x] 5.4 Test: notifications page renders with workspace-scoped notifications
|
|
- [x] 5.5 Test: notifications page pagination works at 25 per page
|
|
- [x] 5.6 Test: empty state renders when no notifications exist
|
|
- [x] 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:
|
|
|
|
```php
|
|
'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:
|
|
```php
|
|
->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:**
|
|
```php
|
|
$workspace = $this->currentWorkspace();
|
|
// Use HasWorkspaceScope trait
|
|
$this->authorizeWorkspaceAccess($model); // aborts 404 if wrong workspace
|
|
```
|
|
|
|
**Cache invalidation after state change:**
|
|
```php
|
|
Cache::forget("user:{$userId}:unread_notifications");
|
|
```
|
|
|
|
**Flash messages:**
|
|
```php
|
|
return back()->with('flash', ['type' => 'success', 'message' => '...']);
|
|
```
|
|
|
|
**Vue component pattern:**
|
|
```vue
|
|
<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
|