feat: add bulk client notifications and email enhancements with review fixes (Stories 3.4 & 3.5)

Story 3-4: Bulk client notification scheduling — BulkNotificationController,
BulkActionBar component, checkbox selection on declarations index.

Story 3-5: Email notification enhancement — observer-driven email on
en_attente_client, cache invalidation on ferme, workspace branding on
all email templates, 11 feature tests.

Code review fixes:
- Move bulk-notify route above resource wildcard to prevent shadowing
- Add static $suppressEmail flag to prevent observer double-sending
  when DeclarationMessageController already sends the email
- Fix canBulkNotify logic (was granting workers access)
- Add WorkspaceUserRole check to BulkNotifyRequest::authorize()
- Replace firstOrCreate with explicit invitation lookup that syncs
  client email and handles used/expired invitations correctly
- Watch declarations.data instead of current_page to clear selection
  on filter/sort changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:31:36 +01:00
parent 32e11db2b5
commit 1d4f3bcd0f
17 changed files with 1384 additions and 7 deletions

View File

@@ -0,0 +1,266 @@
# Story 3.4: Bulk Client Notification Scheduling
Status: review
## Story
As a firm worker or manager,
I want to send document request notifications to multiple clients at once,
so that I can efficiently request missing documents without emailing each client individually.
## Acceptance Criteria (BDD)
1. **Given** a user (Owner/Manager/Worker with assigned declarations) is viewing the declarations list
**When** they select multiple declarations with status `en_attente_client` using row checkboxes
**Then** a BulkActionBar appears showing "[N] selected" and a "Notify Clients" button
2. **Given** the user clicks "Notify Clients" on the BulkActionBar
**When** the confirmation dialog appears
**Then** it shows the count of clients to be notified and a "Send Notifications" confirmation button
3. **Given** the user clicks "Send" in the confirmation dialog
**When** the POST request is dispatched to the bulk notification endpoint
**Then** each client receives a personalized email with their token-based portal link for document upload
**And** emails are queued individually via Redis (non-blocking -- controller returns immediately)
**And** a success toast confirms: "[N] notifications envoyees"
4. **Given** 50 client notifications are scheduled
**When** the queue processes them
**Then** all 50 emails are sent within 10 seconds (NFR3)
**And** failed individual sends retry automatically (up to 3 retries per NFR26)
**And** the bulk operation is logged via Spatie Activity Log with the count and actor
5. **Given** a Worker user selects declarations
**Then** they can only bulk-notify clients for their own assigned declarations (enforced server-side via `scopeForUser`)
6. **Given** the existing email template `DeclarationFileRequestMail` exists
**Then** it is reused for the notification content (no new mailable needed)
## Tasks / Subtasks
- [x] Task 1: Backend - BulkNotificationController (AC: 1,2,3,4,5)
- [x] 1.1 Create `app/Http/Controllers/BulkNotificationController.php` with `store()` method
- [x] 1.2 Use `HasWorkspaceScope` trait, validate request input (array of declaration IDs)
- [x] 1.3 Load declarations with workspace scoping + `scopeForUser` for Workers
- [x] 1.4 Filter to only `en_attente_client` status declarations server-side
- [x] 1.5 For each declaration: find/create `DeclarationInvitation`, dispatch `DeclarationFileRequestMail` via queue
- [x] 1.6 Log bulk operation via `activity()->performedOn($workspace)->causedBy($user)->withProperties(['count' => $count, 'declaration_ids' => $ids])->log('bulk_client_notification')`
- [x] 1.7 Return immediately with flash success message (non-blocking)
- [x] Task 2: Backend - Route registration (AC: 3)
- [x] 2.1 Add `POST /declarations/bulk-notify` route in `web.php` with `throttle:5,1` middleware
- [x] 2.2 Name: `declarations.bulk-notify`
- [x] Task 3: Frontend - Row selection on declarations Index (AC: 1)
- [x] 3.1 Add checkbox column to declarations table in `resources/js/Pages/declarations/Index.vue`
- [x] 3.2 Track selected declaration IDs in reactive state
- [x] 3.3 Only allow selection of rows with status `en_attente_client`
- [x] 3.4 Add select-all checkbox in header (filtered to eligible rows only)
- [x] Task 4: Frontend - BulkActionBar component (AC: 1,2)
- [x] 4.1 Create `resources/js/components/declarations/BulkActionBar.vue`
- [x] 4.2 Fixed bottom bar showing "[N] selected" count and "Notify Clients" button
- [x] 4.3 Only visible when `selectedIds.length > 0`
- [x] 4.4 Mobile: render as bottom sheet (responsive)
- [x] Task 5: Frontend - Confirmation dialog and submission (AC: 2,3)
- [x] 5.1 Use shadcn-vue `Dialog` for confirmation (AlertDialog not installed, Dialog used instead)
- [x] 5.2 Show client count and "Send Notifications" primary button
- [x] 5.3 POST to `declarations.bulk-notify` route via Inertia `router.post()`
- [x] 5.4 Disable button during processing with spinner
- [x] 5.5 Show success toast on completion, clear selection state
- [x] Task 6: Backend - Pass bulk-notify URL and selection eligibility to frontend (AC: 1,5)
- [x] 6.1 Add `bulkNotifyUrl` prop from `DeclarationController::index()` via `route('declarations.bulk-notify')`
- [x] 6.2 Add `canBulkNotify` boolean prop (Owner/Manager always true, Worker true if has assignments)
- [x] Task 7: Tests (AC: all)
- [x] 7.1 Feature test: successful bulk notification dispatch for Owner/Manager
- [x] 7.2 Feature test: Worker can only bulk-notify own assigned declarations
- [x] 7.3 Feature test: rejects declarations not in `en_attente_client` status
- [x] 7.4 Feature test: workspace boundary enforcement (abort 404)
- [x] 7.5 Feature test: activity log records bulk operation
- [x] 7.6 Feature test: throttle middleware prevents abuse
## Retrospective Intelligence
### From Epic 2 Retro (2026-03-24)
- **Deferred item D-1:** Nudge/Reassign dropdown items were unconditionally disabled in Story 2.3 -- resolved in Story 3.2. No carryover for 3.4.
- **Deferred item D-3:** Cache not invalidated on role change (5-min TTL mitigates). If bulk notifications touch notification counts, invalidate cache per `Cache::forget("user:{$userId}:workspace:{$workspaceId}:unread_notifications")`.
- **Pre-existing test failures:** 15 failures were resolved in commit `716e9fc`. Ensure no new regressions.
### From Epic 1 Retro (2026-03-20)
- **withPivot gotcha:** Always use `->withPivot('role', 'permissions')` when accessing `WorkspaceUser` pivot. This hit 3/6 stories in Epic 1.
- **Previous Story Intelligence** sections in story specs enable knowledge transfer -- validated pattern.
### From Epic 0 Retro (2026-03-13)
- Layered migration strategy works well. No new migrations needed for this story.
## Dev Notes
### Architecture Patterns (MUST FOLLOW)
| Pattern | Implementation | Source |
|---------|---------------|--------|
| Workspace scoping | `HasWorkspaceScope` trait, `abort(404)` for boundary violations | architecture.md D7 |
| Queued notifications | `ShouldQueue` trait, `$tries = 3`, `$backoff = 60` | architecture.md D5 |
| Bulk operations | `DB::transaction()` for validation, Redis queue for sends | architecture.md D9 |
| Activity logging | `activity()->performedOn()->causedBy()->log()` | NudgeController pattern |
| Cache invalidation | `Cache::forget("user:{id}:workspace:{id}:unread_notifications")` | NotificationController pattern |
### Existing Code to Reuse (DO NOT REINVENT)
| What | Where | How to Reuse |
|------|-------|-------------|
| `DeclarationFileRequestMail` | `app/Mail/DeclarationFileRequestMail.php` | Use directly -- accepts `Declaration`, `DeclarationInvitation`, `string body` |
| `DeclarationInvitation` model | `app/Models/DeclarationInvitation.php` | Creates token-based portal links for clients |
| `HasWorkspaceScope` trait | `app/Concerns/HasWorkspaceScope.php` | `use HasWorkspaceScope` + `$this->authorizeWorkspaceAccess()` |
| `NotificationType::BulkNotification` | `app/Enums/NotificationType.php` | Already defined as `'bulk_notification'` with label `'Notification groupee'` |
| `Declaration::scopeForUser()` | `app/Models/Declaration.php` | Scopes to assigned declarations for Workers |
| Frontend `bulk_notification` icon | `resources/js/Pages/notifications/Index.vue` | Already mapped to `Mail` icon from lucide |
| `NudgeController` | `app/Http/Controllers/NudgeController.php` | Reference pattern for workspace auth + activity logging |
### Controller Pattern (Follow NudgeController)
```php
// BulkNotificationController.php structure:
class BulkNotificationController extends Controller
{
use HasWorkspaceScope;
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'declaration_ids' => ['required', 'array', 'min:1'],
'declaration_ids.*' => ['integer'],
]);
$workspace = $this->currentWorkspace();
$user = $request->user();
$workspaceUser = $workspace->users()->where('users.id', $user->id)->firstOrFail();
// Load declarations with workspace + user scoping
$declarations = Declaration::where('workspace_id', $workspace->id)
->forUser($user, $workspaceUser)
->where('status', 'en_attente_client')
->whereIn('id', $validated['declaration_ids'])
->get();
// Queue emails (non-blocking)
foreach ($declarations as $declaration) {
// Find or create invitation with token for client portal
$invitation = $declaration->invitations()->firstOrCreate([...]);
Mail::to($declaration->client->email)
->queue(new DeclarationFileRequestMail($declaration, $invitation, $body));
}
// Activity log
activity()
->performedOn($workspace)
->causedBy($user)
->withProperties(['count' => $declarations->count(), 'declaration_ids' => $declarations->pluck('id')])
->log('bulk_client_notification');
// Invalidate notification caches for affected users if database notifications created
return back()->with('flash', [
'type' => 'success',
'message' => $declarations->count() . ' notifications envoyees',
]);
}
}
```
### Declaration Status Values
Per project-context.md: `created`, `en_cours`, `en_attente_client`, `mise_en_demeure`, `termine`, `ferme`
Only declarations with status `en_attente_client` are eligible for bulk client notifications.
### Frontend Implementation Notes
- **Checkbox column:** Add as first column in the declarations table. Use reactive `ref<number[]>([])` for selected IDs.
- **BulkActionBar:** Fixed position bottom bar using Tailwind `fixed bottom-0`. Use `Transition` for show/hide animation.
- **Confirmation:** Use shadcn-vue `AlertDialog` (same pattern as delete confirmations in the codebase).
- **Submission:** Use `router.post(props.bulkNotifyUrl, { declaration_ids: selectedIds.value })` -- NOT `useForm()` since there's no traditional form.
- **Toast:** Already handled by flash message system via `back()->with('flash', ...)`.
- **Props to add to Index.vue:** `bulkNotifyUrl: string`, `canBulkNotify: boolean`.
### Testing Standards
- Use Pest `test()` closures with lowercase descriptions
- `RefreshDatabase` auto-applied via Pest.php
- Test file: `tests/Feature/Notifications/BulkNotificationTest.php`
- Assert `Mail::fake()` + `Mail::assertQueued(DeclarationFileRequestMail::class, $count)`
- Assert `Activity` model for log entries
- Test workspace isolation: create declaration in different workspace, assert 404
### File Structure
```
app/Http/Controllers/BulkNotificationController.php (NEW)
resources/js/components/declarations/BulkActionBar.vue (NEW)
resources/js/Pages/declarations/Index.vue (MODIFY - add checkboxes, bulk bar)
app/Http/Controllers/DeclarationController.php (MODIFY - add bulkNotifyUrl prop)
routes/web.php (MODIFY - add bulk-notify route)
tests/Feature/Notifications/BulkNotificationTest.php (NEW)
```
### Project Structure Notes
- Controller follows single-action pattern in `app/Http/Controllers/` (consistent with NudgeController)
- Vue component in `resources/js/components/declarations/` (co-located with NudgePopover)
- Test in `tests/Feature/Notifications/` (consistent with notification test organization)
- No new migrations, models, or enums required -- all infrastructure exists from Stories 3.1-3.3
### Performance (NFR3)
- 50 emails within 10 seconds is achievable because emails are individually queued to Redis
- Controller returns immediately (non-blocking) -- queue workers handle actual sends
- Each `DeclarationFileRequestMail` is already `Queueable` with `SerializesModels`
- Redis queue processes jobs concurrently via multiple workers
### References
- [Source: _bmad-output/planning-artifacts/epics.md - Epic 3, Story 3.4]
- [Source: _bmad-output/planning-artifacts/architecture.md - D5 Queued Notifications, D7 Workspace Scoping, D8 DatabaseNotification, D9 Bulk Operations]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md - BulkActionBar, Confirmation Dialogs, Toast Patterns]
- [Source: _bmad-output/planning-artifacts/prd.md - FR32 Bulk Notifications, NFR3, NFR26]
- [Source: _bmad-output/project-context.md - Declaration statuses, withPivot gotcha, workspace scoping]
- [Source: _bmad-output/implementation-artifacts/3-3-notification-center-and-bell.md - Previous story patterns]
- [Source: _bmad-output/implementation-artifacts/epic-2-retrospective.md - Deferred items D-1, D-3]
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6 (1M context)
### Debug Log References
- Used Dialog instead of AlertDialog (not installed) for confirmation modal -- functionally equivalent
- Used BulkNotifyRequest Form Request class instead of inline validation per project-context.md convention
- Used `user->currentWorkspaceUser()` memoized method instead of re-querying workspace users
### Completion Notes List
- Created BulkNotificationController with store() method following NudgeController patterns
- Controller uses HasWorkspaceScope, BulkNotifyRequest validation, workspace+user scoping, en_attente_client filter
- Each declaration gets a DeclarationInvitation (firstOrCreate) and queued DeclarationFileRequestMail
- Activity logged on workspace with count and declaration_ids properties
- Route registered as POST /declarations/bulk-notify with throttle:5,1 middleware
- Index.vue updated with checkbox column (only en_attente_client rows), select-all header checkbox
- BulkActionBar component created with fixed bottom bar, selection count, animated show/hide
- Confirmation dialog shows client count, disables button with spinner during processing
- bulkNotifyUrl and canBulkNotify props passed from DeclarationController::index()
- 9 feature tests covering all ACs: owner/manager bulk-notify, worker scoping, status filtering, workspace isolation, activity logging, throttle, auth, validation
- Full regression suite: 263 tests pass, 0 failures
### Change Log
- 2026-03-26: Implemented Story 3.4 - Bulk Client Notification Scheduling (all 7 tasks complete)
### File List
- app/Http/Controllers/BulkNotificationController.php (NEW)
- app/Http/Requests/BulkNotifyRequest.php (NEW)
- resources/js/components/declarations/BulkActionBar.vue (NEW)
- tests/Feature/Notifications/BulkNotificationTest.php (NEW)
- resources/js/Pages/declarations/Index.vue (MODIFIED - added checkboxes, BulkActionBar integration)
- app/Http/Controllers/DeclarationController.php (MODIFIED - added bulkNotifyUrl, canBulkNotify props)
- routes/web.php (MODIFIED - added declarations.bulk-notify route)

View File

@@ -0,0 +1,233 @@
# Story 3.5: Email Notification Enhancement for Key Events
Status: review
## Story
As a platform user,
I want to receive email notifications for important events (nudges, document uploads, status changes),
So that I stay informed about critical actions even when I'm not actively using the platform.
## Acceptance Criteria
1. **AC1 — Nudge email enhancement**: Given a worker receives a nudge from a manager, when the `NudgeNotification` processes via queue, then the worker receives an email with: sender name, declaration details (client, type, deadline), and a direct link button ("Voir la declaration"). The email uses the existing `NudgeNotificationMail` Markdown mailable with professional French copy.
2. **AC2 — Document upload: database-only (no email)**: Given a client uploads a document via the portal, when the upload is confirmed, then the assigned worker receives a `DocumentUploadedNotification` in-app (database channel only — no email for uploads to avoid noise).
3. **AC3 — Status change: en_attente_client triggers client email**: Given a declaration's status changes, when the `DeclarationObserver` fires and the new status is `en_attente_client`, then the client receives the existing document request email via their token link (reuses `DeclarationFileRequestMail`).
4. **AC4 — Status change: ferme triggers cache invalidation**: Given a declaration's status changes to `ferme`, when the `DeclarationObserver` fires, then dashboard cache is invalidated (from Story 2.1 `Cache::forget` pattern).
5. **AC5 — Email retry on failure**: Given email delivery fails, when the queue retries, then the system retries up to 3 times within 5 minutes (NFR26), and permanently failed emails are logged to the `failed_jobs` table for monitoring. All queued emails use `ShouldQueue` and `Queueable` traits.
6. **AC6 — Business context filtering**: Email notifications respect business context: no email is sent for minor status transitions (e.g., `created` -> `en_cours`). Only `en_attente_client` (triggers client email) and `ferme` (triggers cache invalidation) have side effects.
7. **AC7 — Workspace branding**: All email templates include the workspace firm name and logo in the header.
## Tasks / Subtasks
- [x] Task 1: Enhance `DeclarationObserver` with notification dispatching (AC: #3, #4, #6)
- [x] 1.1 Add `updated()` hook to `DeclarationObserver` (separate from `updating()` — runs after save)
- [x] 1.2 When status changes to `en_attente_client`: look up the declaration's client, find or create `DeclarationInvitation` with token, queue `DeclarationFileRequestMail` to the client email
- [x] 1.3 When status changes to `ferme`: invalidate dashboard cache via `Cache::forget("dashboard:{$workspace_id}:*")` pattern
- [x] 1.4 Skip email for all other transitions (`created->en_cours`, `en_cours->en_attente_client` already handled, `termine->ferme`, etc.)
- [x] 1.5 Dispatch `StatusChangedNotification` (database channel) to assigned worker for status changes they didn't initiate (optional enhancement — only if straightforward)
- [x] Task 2: Verify nudge email flow is complete (AC: #1)
- [x] 2.1 Confirm `NudgeNotification` already sends via `mail` channel with `NudgeNotificationMail` — this is ALREADY IMPLEMENTED in Story 3.2
- [x] 2.2 Verify `nudge-notification.blade.php` has professional French copy with sender name, client, type, due_date, "Voir la declaration" button — ALREADY EXISTS
- [x] 2.3 If any gaps found, fix them; otherwise mark as already complete
- [x] Task 3: Verify DocumentUploadedNotification stays database-only (AC: #2)
- [x] 3.1 Confirm `DocumentUploadedNotification` `via()` returns `['database']` only — ALREADY IMPLEMENTED in Story 3.1
- [x] 3.2 No changes needed unless a gap is found
- [x] Task 4: Add workspace branding to email templates (AC: #7)
- [x] 4.1 Ensure `firmName` (workspace name) is passed to all email templates that don't already have it
- [x] 4.2 Update `declaration-overdue.blade.php` to include firm name in header (currently missing)
- [x] 4.3 Verify `nudge-notification.blade.php` already includes `firmName` — it does
- [x] 4.4 For logo: pass `workspace.logo_url` if the field exists, otherwise skip logo (workspace model may not have logo yet — do NOT add a migration for this)
- [x] Task 5: Ensure retry configuration on all email-sending notifications (AC: #5)
- [x] 5.1 Verify `NudgeNotification` has `$tries = 3`, `$backoff = 60` — ALREADY SET
- [x] 5.2 Verify `DeclarationOverdueNotification` has `$tries = 3`, `$backoff = 60` — ALREADY SET
- [x] 5.3 Any new notification classes must follow the same pattern
- [x] Task 6: Write feature tests (AC: all)
- [x] 6.1 Test: status change to `en_attente_client` triggers `DeclarationFileRequestMail` queue
- [x] 6.2 Test: status change to `ferme` invalidates dashboard cache
- [x] 6.3 Test: status change to `en_cours` does NOT trigger any email
- [x] 6.4 Test: `DocumentUploadedNotification` has no mail channel
- [x] 6.5 Test: nudge email contains required fields (sender, client, type, due_date, link)
- [x] 6.6 Test: workspace scoping on all notification dispatches
- [x] 6.7 Test: failed email retries (verify `$tries` and `$backoff` properties)
## Retrospective Intelligence
- **Load retro as context is non-negotiable** (Epic 2 retro team agreement). This story incorporates all retro lessons.
- **`withPivot('role', 'permissions')` gotcha** — any query touching `workspace_user` pivot must chain this, or pivot data is null. Relevant if checking user roles during notification dispatch.
- **`due_date` not `deadline`** — architecture doc drift identified in Epic 2 retro. The column is `due_date` everywhere in code.
- **Wayfinder routes only** — no hardcoded URLs in Vue. Use `route()` helper in PHP for email links. This is already the pattern in `NudgeNotificationMail` (uses `route('declarations.show', $this->declaration)`).
- **`withoutVite()` in tests** — global Pest.php workaround from Story 2.4. Tests should work without Vite running.
- **Cache invalidation pattern**`Cache::forget("dashboard:{$workspaceId}:{$userId}")` established in Story 2.1. For bulk invalidation across all workspace users, iterate workspace members or use a tag-based approach.
- **Pre-existing test failures were fixed** in commit `716e9fc`. Test suite should be green.
- **DB::transaction for multi-step operations** — established pattern. Use for invitation creation + email queueing if doing both.
## Dev Notes
### What Already Works (DO NOT Rebuild)
**Nudge email flow is COMPLETE** — `NudgeNotification` dispatches via `['database', 'mail']` channels. `NudgeNotificationMail` uses `emails.nudge-notification` Markdown template with sender name, firm name, client, type, due date, and "Voir la declaration" button. Retry configured at 3 tries / 60s backoff. **No changes needed unless testing reveals a gap.**
**DocumentUploadedNotification is database-only by design** — `via()` returns `['database']`. The AC explicitly says "no email for uploads to avoid noise." **No changes needed.**
**DeclarationOverdueNotification has mail channel** — uses `MailMessage` with `emails.declaration-overdue` Markdown template. Retry configured. **No changes needed unless branding update required.**
**Existing Mailable classes** — `DeclarationFileRequestMail`, `DeclarationConfirmationMail`, etc. already exist in `app/Mail/`. Reuse `DeclarationFileRequestMail` for the `en_attente_client` status change flow.
### What Needs to Be Built
**Primary new work: Enhance `DeclarationObserver`** to dispatch notifications/emails on status transitions:
1. Add `updated()` method (runs AFTER save, unlike `updating()` which runs before):
- Check if `status` changed via `$declaration->isDirty('status')` — BUT note: in `updated()`, use `$declaration->wasChanged('status')` instead of `isDirty()` since the model has already been saved
- If new status is `en_attente_client`: queue client email via existing `DeclarationFileRequestMail`
- If new status is `ferme`: bust dashboard cache for all workspace users
2. **Client email on `en_attente_client`**:
- Load the declaration's client: `$declaration->client`
- Find or create a `DeclarationInvitation` with a unique token for the client portal link
- Queue `DeclarationFileRequestMail` to the client's email address
- The `DeclarationInvitation` model and token-based portal already exist (from the brownfield codebase)
3. **Cache invalidation on `ferme`**:
- The `updating()` hook already sets `archived_at = now()` for `ferme`
- In `updated()`, also bust dashboard cache: get workspace users, `Cache::forget()` for each
### Architecture Compliance
- **Observer pattern**: `DeclarationObserver` is already registered in `AppServiceProvider`. Add `updated()` alongside existing `updating()`.
- **Queue pattern**: All email sends must be queued (`ShouldQueue` + `Queueable`). Never send synchronously.
- **Workspace scoping**: Notifications include `workspace_id` in payload. Email dispatch respects workspace boundaries.
- **Activity logging**: Status changes are already logged by `LogsActivity` trait on Declaration model. No additional logging needed.
- **Error handling**: Failed emails go to `failed_jobs` table automatically via Laravel queue worker (`--tries=3`).
### File Structure
**Files to modify:**
- `app/Observers/DeclarationObserver.php` — Add `updated()` method with email dispatch and cache invalidation
- `resources/views/emails/declaration-overdue.blade.php` — Add firm name to template header (workspace branding)
**Files to verify (no changes expected):**
- `app/Notifications/NudgeNotification.php` — Confirm mail channel and retry config
- `app/Notifications/DocumentUploadedNotification.php` — Confirm database-only
- `app/Notifications/DeclarationOverdueNotification.php` — Confirm retry config
- `app/Mail/NudgeNotificationMail.php` — Confirm template data
- `resources/views/emails/nudge-notification.blade.php` — Confirm French copy
**Files to create:**
- `tests/Feature/Notifications/EmailNotificationTest.php` — New test file for this story's ACs
### Existing Code Patterns to Follow
**Observer `updated()` pattern:**
```php
public function updated(Declaration $declaration): void
{
if (! $declaration->wasChanged('status')) {
return;
}
// Use wasChanged() not isDirty() in updated() hook
$newStatus = $declaration->status instanceof DeclarationStatus
? $declaration->status->value
: (string) $declaration->status;
// Dispatch based on new status...
}
```
**Mail queueing pattern (from BulkNotificationController):**
```php
Mail::to($client->email)->queue(
new DeclarationFileRequestMail($declaration, $invitation, $body)
);
```
**Cache invalidation pattern (from DashboardController):**
```php
// Bust cache for all workspace users
$workspaceUsers = $workspace->users()->pluck('users.id');
foreach ($workspaceUsers as $userId) {
Cache::forget("dashboard:{$workspace->id}:{$userId}");
}
```
**Test pattern (Pest, from BulkNotificationTest):**
```php
test('status change to en_attente_client queues client email', function () {
Mail::fake();
// ... setup declaration, change status ...
Mail::assertQueued(DeclarationFileRequestMail::class);
});
```
### Project Structure Notes
- All code follows established directory conventions. No new directories needed.
- Observer is already wired in `AppServiceProvider` boot method.
- Email templates live in `resources/views/emails/` using Markdown mailable syntax (`<x-mail::message>`).
- Tests go in `tests/Feature/Notifications/` alongside existing notification tests.
### Critical Gotchas
1. **`isDirty()` vs `wasChanged()`**: In `updating()` (before save), use `isDirty()`. In `updated()` (after save), use `wasChanged()`. The existing `updating()` correctly uses `isDirty()`. The new `updated()` must use `wasChanged()`.
2. **DeclarationInvitation token**: Check how `BulkNotificationController` creates invitations. It likely creates a `DeclarationInvitation` record with a unique token. Reuse that exact pattern — do NOT invent a new token mechanism.
3. **Client email address**: Clients are stored in the `clients` table with an `email` field. Access via `$declaration->client->email`. Handle null email gracefully (skip sending, don't crash).
4. **Mail::to() vs Notification::send()**: For client emails, use `Mail::to()->queue()` directly (clients are not `Notifiable` users). For internal user notifications, use `$user->notify(new SomeNotification())`.
5. **Cache invalidation scope**: When busting dashboard cache on `ferme`, only bust for users in the same workspace. Get workspace users via `$declaration->workspace->users()`.
6. **Observer infinite loop risk**: The `updated()` hook must NOT modify the declaration model (which would trigger another `updating`/`updated` cycle). Only dispatch external side effects (emails, cache busting).
### References
- [Source: _bmad-output/planning-artifacts/epics.md — Epic 3, Story 3.5]
- [Source: _bmad-output/planning-artifacts/architecture.md — D8: In-App Notification System]
- [Source: _bmad-output/planning-artifacts/architecture.md — D9: Bulk Operation Processing]
- [Source: _bmad-output/planning-artifacts/architecture.md — D5: Queue Driver (Redis)]
- [Source: _bmad-output/planning-artifacts/architecture.md — D13: Email Service Provider (Amazon SES)]
- [Source: _bmad-output/implementation-artifacts/epic-2-retro-2026-03-24.md — Cache invalidation, withPivot gotchas]
- [Source: _bmad-output/implementation-artifacts/epic-1-retro-2026-03-20.md — Wayfinder routes, DB::transaction]
- [Source: app/Observers/DeclarationObserver.php — Existing updating() hook]
- [Source: app/Notifications/NudgeNotification.php — Mail channel pattern]
- [Source: app/Mail/NudgeNotificationMail.php — Mailable envelope/content pattern]
- [Source: app/Http/Controllers/BulkNotificationController.php — Mail queue + invitation creation pattern]
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6 (1M context)
### Debug Log References
- Full test suite: 274 passed, 0 failures (including 11 new tests)
### Completion Notes List
- **Task 1**: Added `updated()` hook to `DeclarationObserver` with two private methods: `sendClientFileRequestEmail()` (queues `DeclarationFileRequestMail` on `en_attente_client` using DB transaction + invitation creation pattern from `BulkNotificationController`) and `invalidateDashboardCache()` (busts `dashboard:{workspace_id}:{user_id}` cache for all workspace users on `ferme`). Uses `wasChanged()` (not `isDirty()`) as required in post-save hook. Skipped optional Task 1.5 (StatusChangedNotification) as it would add complexity beyond story scope.
- **Task 2**: Verified nudge email flow is fully implemented — `NudgeNotification` sends via `['database', 'mail']`, `NudgeNotificationMail` renders `nudge-notification.blade.php` with all required fields. No gaps found.
- **Task 3**: Verified `DocumentUploadedNotification::via()` returns `['database']` only. No changes needed.
- **Task 4**: Added `firmName` (workspace name) to `DeclarationOverdueNotification` mail data and `declaration-overdue.blade.php` header. Added `firmName` to `DeclarationFileRequestMail` content and `declaration-file-request.blade.php` header. Nudge template already had it. Workspace has no `logo_url` field — skipped logo per story instructions.
- **Task 5**: Verified retry config (`$tries = 3`, `$backoff = 60`) on `NudgeNotification` and `DeclarationOverdueNotification`. No new notification classes created.
- **Task 6**: Created 11 feature tests covering all ACs: email dispatch on `en_attente_client`, cache invalidation on `ferme`, no email on `en_cours`, database-only channel check, nudge email content, workspace scoping, and retry properties.
### Change Log
- 2026-03-26: Implemented Story 3.5 — Email notification enhancement for key events. Added observer-driven email dispatch and cache invalidation, workspace branding on all email templates, 11 new feature tests. All 274 tests pass.
### File List
- `app/Observers/DeclarationObserver.php` — Modified: added `updated()` method with email dispatch and cache invalidation
- `app/Notifications/DeclarationOverdueNotification.php` — Modified: added `firmName` to mail data
- `app/Mail/DeclarationFileRequestMail.php` — Modified: added `firmName` to template content
- `resources/views/emails/declaration-overdue.blade.php` — Modified: added firm name in header
- `resources/views/emails/declaration-file-request.blade.php` — Modified: added firm name in header
- `tests/Feature/Notifications/EmailNotificationTest.php` — Created: 11 feature tests for all ACs

View File

@@ -35,6 +35,10 @@
generated: 2026-03-11
last_updated: "2026-03-26"
# Story 3-5 dev completed: 2026-03-26
# Story 3-5 contexted: 2026-03-26
# Story 3-4 dev started: 2026-03-26
# Story 3-4 contexted: 2026-03-26
project: "l'ami fiduciaire"
project_key: NOKEY
tracking_system: file-system
@@ -72,9 +76,9 @@ development_status:
epic-3: in-progress
3-1-notification-infrastructure-setup: done
3-2-one-click-nudge-system: done
3-3-notification-center-and-bell: review
3-4-bulk-client-notification-scheduling: backlog
3-5-email-notification-enhancement-for-key-events: backlog
3-3-notification-center-and-bell: done
3-4-bulk-client-notification-scheduling: done
3-5-email-notification-enhancement-for-key-events: review
epic-3-retrospective: optional
# Epic 4: Bulk Operations, Search & Advanced Filtering