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>
234 lines
16 KiB
Markdown
234 lines
16 KiB
Markdown
# 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
|