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:
@@ -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)
|
||||
Reference in New Issue
Block a user