# 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([])` 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)