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>
16 KiB
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
-
AC1 — Nudge email enhancement: Given a worker receives a nudge from a manager, when the
NudgeNotificationprocesses 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 existingNudgeNotificationMailMarkdown mailable with professional French copy. -
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
DocumentUploadedNotificationin-app (database channel only — no email for uploads to avoid noise). -
AC3 — Status change: en_attente_client triggers client email: Given a declaration's status changes, when the
DeclarationObserverfires and the new status isen_attente_client, then the client receives the existing document request email via their token link (reusesDeclarationFileRequestMail). -
AC4 — Status change: ferme triggers cache invalidation: Given a declaration's status changes to
ferme, when theDeclarationObserverfires, then dashboard cache is invalidated (from Story 2.1Cache::forgetpattern). -
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_jobstable for monitoring. All queued emails useShouldQueueandQueueabletraits. -
AC6 — Business context filtering: Email notifications respect business context: no email is sent for minor status transitions (e.g.,
created->en_cours). Onlyen_attente_client(triggers client email) andferme(triggers cache invalidation) have side effects. -
AC7 — Workspace branding: All email templates include the workspace firm name and logo in the header.
Tasks / Subtasks
-
Task 1: Enhance
DeclarationObserverwith notification dispatching (AC: #3, #4, #6)- 1.1 Add
updated()hook toDeclarationObserver(separate fromupdating()— runs after save) - 1.2 When status changes to
en_attente_client: look up the declaration's client, find or createDeclarationInvitationwith token, queueDeclarationFileRequestMailto the client email - 1.3 When status changes to
ferme: invalidate dashboard cache viaCache::forget("dashboard:{$workspace_id}:*")pattern - 1.4 Skip email for all other transitions (
created->en_cours,en_cours->en_attente_clientalready handled,termine->ferme, etc.) - 1.5 Dispatch
StatusChangedNotification(database channel) to assigned worker for status changes they didn't initiate (optional enhancement — only if straightforward)
- 1.1 Add
-
Task 2: Verify nudge email flow is complete (AC: #1)
- 2.1 Confirm
NudgeNotificationalready sends viamailchannel withNudgeNotificationMail— this is ALREADY IMPLEMENTED in Story 3.2 - 2.2 Verify
nudge-notification.blade.phphas professional French copy with sender name, client, type, due_date, "Voir la declaration" button — ALREADY EXISTS - 2.3 If any gaps found, fix them; otherwise mark as already complete
- 2.1 Confirm
-
Task 3: Verify DocumentUploadedNotification stays database-only (AC: #2)
- 3.1 Confirm
DocumentUploadedNotificationvia()returns['database']only — ALREADY IMPLEMENTED in Story 3.1 - 3.2 No changes needed unless a gap is found
- 3.1 Confirm
-
Task 4: Add workspace branding to email templates (AC: #7)
- 4.1 Ensure
firmName(workspace name) is passed to all email templates that don't already have it - 4.2 Update
declaration-overdue.blade.phpto include firm name in header (currently missing) - 4.3 Verify
nudge-notification.blade.phpalready includesfirmName— it does - 4.4 For logo: pass
workspace.logo_urlif the field exists, otherwise skip logo (workspace model may not have logo yet — do NOT add a migration for this)
- 4.1 Ensure
-
Task 5: Ensure retry configuration on all email-sending notifications (AC: #5)
- 5.1 Verify
NudgeNotificationhas$tries = 3,$backoff = 60— ALREADY SET - 5.2 Verify
DeclarationOverdueNotificationhas$tries = 3,$backoff = 60— ALREADY SET - 5.3 Any new notification classes must follow the same pattern
- 5.1 Verify
-
Task 6: Write feature tests (AC: all)
- 6.1 Test: status change to
en_attente_clienttriggersDeclarationFileRequestMailqueue - 6.2 Test: status change to
fermeinvalidates dashboard cache - 6.3 Test: status change to
en_coursdoes NOT trigger any email - 6.4 Test:
DocumentUploadedNotificationhas no mail channel - 6.5 Test: nudge email contains required fields (sender, client, type, due_date, link)
- 6.6 Test: workspace scoping on all notification dispatches
- 6.7 Test: failed email retries (verify
$triesand$backoffproperties)
- 6.1 Test: status change to
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 touchingworkspace_userpivot must chain this, or pivot data is null. Relevant if checking user roles during notification dispatch.due_datenotdeadline— architecture doc drift identified in Epic 2 retro. The column isdue_dateeverywhere in code.- Wayfinder routes only — no hardcoded URLs in Vue. Use
route()helper in PHP for email links. This is already the pattern inNudgeNotificationMail(usesroute('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:
-
Add
updated()method (runs AFTER save, unlikeupdating()which runs before):- Check if
statuschanged via$declaration->isDirty('status')— BUT note: inupdated(), use$declaration->wasChanged('status')instead ofisDirty()since the model has already been saved - If new status is
en_attente_client: queue client email via existingDeclarationFileRequestMail - If new status is
ferme: bust dashboard cache for all workspace users
- Check if
-
Client email on
en_attente_client:- Load the declaration's client:
$declaration->client - Find or create a
DeclarationInvitationwith a unique token for the client portal link - Queue
DeclarationFileRequestMailto the client's email address - The
DeclarationInvitationmodel and token-based portal already exist (from the brownfield codebase)
- Load the declaration's client:
-
Cache invalidation on
ferme:- The
updating()hook already setsarchived_at = now()forferme - In
updated(), also bust dashboard cache: get workspace users,Cache::forget()for each
- The
Architecture Compliance
- Observer pattern:
DeclarationObserveris already registered inAppServiceProvider. Addupdated()alongside existingupdating(). - Queue pattern: All email sends must be queued (
ShouldQueue+Queueable). Never send synchronously. - Workspace scoping: Notifications include
workspace_idin payload. Email dispatch respects workspace boundaries. - Activity logging: Status changes are already logged by
LogsActivitytrait on Declaration model. No additional logging needed. - Error handling: Failed emails go to
failed_jobstable automatically via Laravel queue worker (--tries=3).
File Structure
Files to modify:
app/Observers/DeclarationObserver.php— Addupdated()method with email dispatch and cache invalidationresources/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 configapp/Notifications/DocumentUploadedNotification.php— Confirm database-onlyapp/Notifications/DeclarationOverdueNotification.php— Confirm retry configapp/Mail/NudgeNotificationMail.php— Confirm template dataresources/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:
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):
Mail::to($client->email)->queue(
new DeclarationFileRequestMail($declaration, $invitation, $body)
);
Cache invalidation pattern (from DashboardController):
// 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):
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
AppServiceProviderboot 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
-
isDirty()vswasChanged(): Inupdating()(before save), useisDirty(). Inupdated()(after save), usewasChanged(). The existingupdating()correctly usesisDirty(). The newupdated()must usewasChanged(). -
DeclarationInvitation token: Check how
BulkNotificationControllercreates invitations. It likely creates aDeclarationInvitationrecord with a unique token. Reuse that exact pattern — do NOT invent a new token mechanism. -
Client email address: Clients are stored in the
clientstable with anemailfield. Access via$declaration->client->email. Handle null email gracefully (skip sending, don't crash). -
Mail::to() vs Notification::send(): For client emails, use
Mail::to()->queue()directly (clients are notNotifiableusers). For internal user notifications, use$user->notify(new SomeNotification()). -
Cache invalidation scope: When busting dashboard cache on
ferme, only bust for users in the same workspace. Get workspace users via$declaration->workspace->users(). -
Observer infinite loop risk: The
updated()hook must NOT modify the declaration model (which would trigger anotherupdating/updatedcycle). 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 toDeclarationObserverwith two private methods:sendClientFileRequestEmail()(queuesDeclarationFileRequestMailonen_attente_clientusing DB transaction + invitation creation pattern fromBulkNotificationController) andinvalidateDashboardCache()(bustsdashboard:{workspace_id}:{user_id}cache for all workspace users onferme). UseswasChanged()(notisDirty()) 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 —
NudgeNotificationsends via['database', 'mail'],NudgeNotificationMailrendersnudge-notification.blade.phpwith all required fields. No gaps found. - Task 3: Verified
DocumentUploadedNotification::via()returns['database']only. No changes needed. - Task 4: Added
firmName(workspace name) toDeclarationOverdueNotificationmail data anddeclaration-overdue.blade.phpheader. AddedfirmNametoDeclarationFileRequestMailcontent anddeclaration-file-request.blade.phpheader. Nudge template already had it. Workspace has nologo_urlfield — skipped logo per story instructions. - Task 5: Verified retry config (
$tries = 3,$backoff = 60) onNudgeNotificationandDeclarationOverdueNotification. No new notification classes created. - Task 6: Created 11 feature tests covering all ACs: email dispatch on
en_attente_client, cache invalidation onferme, no email onen_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: addedupdated()method with email dispatch and cache invalidationapp/Notifications/DeclarationOverdueNotification.php— Modified: addedfirmNameto mail dataapp/Mail/DeclarationFileRequestMail.php— Modified: addedfirmNameto template contentresources/views/emails/declaration-overdue.blade.php— Modified: added firm name in headerresources/views/emails/declaration-file-request.blade.php— Modified: added firm name in headertests/Feature/Notifications/EmailNotificationTest.php— Created: 11 feature tests for all ACs