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>
267 lines
14 KiB
Markdown
267 lines
14 KiB
Markdown
# 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)
|