Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/3-5-email-notification-enhancement-for-key-events.md
Saad Zoubir 1d4f3bcd0f 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>
2026-03-26 14:31:36 +01:00

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

  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

  • Task 1: Enhance DeclarationObserver with notification dispatching (AC: #3, #4, #6)

    • 1.1 Add updated() hook to DeclarationObserver (separate from updating() — runs after save)
    • 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
    • 1.3 When status changes to ferme: invalidate dashboard cache via Cache::forget("dashboard:{$workspace_id}:*") pattern
    • 1.4 Skip email for all other transitions (created->en_cours, en_cours->en_attente_client already 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)
  • Task 2: Verify nudge email flow is complete (AC: #1)

    • 2.1 Confirm NudgeNotification already sends via mail channel with NudgeNotificationMail — this is ALREADY IMPLEMENTED in Story 3.2
    • 2.2 Verify nudge-notification.blade.php has 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
  • Task 3: Verify DocumentUploadedNotification stays database-only (AC: #2)

    • 3.1 Confirm DocumentUploadedNotification via() returns ['database'] only — ALREADY IMPLEMENTED in Story 3.1
    • 3.2 No changes needed unless a gap is found
  • 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.php to include firm name in header (currently missing)
    • 4.3 Verify nudge-notification.blade.php already includes firmName — it does
    • 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)
  • Task 5: Ensure retry configuration on all email-sending notifications (AC: #5)

    • 5.1 Verify NudgeNotification has $tries = 3, $backoff = 60 — ALREADY SET
    • 5.2 Verify DeclarationOverdueNotification has $tries = 3, $backoff = 60 — ALREADY SET
    • 5.3 Any new notification classes must follow the same pattern
  • Task 6: Write feature tests (AC: all)

    • 6.1 Test: status change to en_attente_client triggers DeclarationFileRequestMail queue
    • 6.2 Test: status change to ferme invalidates dashboard cache
    • 6.3 Test: status change to en_cours does NOT trigger any email
    • 6.4 Test: DocumentUploadedNotification has 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 $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 patternCache::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 COMPLETENudgeNotification 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 designvia() 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 classesDeclarationFileRequestMail, 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:

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 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