# 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
