Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/3-4-bulk-client-notification-scheduling.md
Saad Zoubir 1d4f3bcd0f 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>
2026-03-26 14:31:36 +01:00

14 KiB

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

  • Task 1: Backend - BulkNotificationController (AC: 1,2,3,4,5)

    • 1.1 Create app/Http/Controllers/BulkNotificationController.php with store() method
    • 1.2 Use HasWorkspaceScope trait, validate request input (array of declaration IDs)
    • 1.3 Load declarations with workspace scoping + scopeForUser for Workers
    • 1.4 Filter to only en_attente_client status declarations server-side
    • 1.5 For each declaration: find/create DeclarationInvitation, dispatch DeclarationFileRequestMail via queue
    • 1.6 Log bulk operation via activity()->performedOn($workspace)->causedBy($user)->withProperties(['count' => $count, 'declaration_ids' => $ids])->log('bulk_client_notification')
    • 1.7 Return immediately with flash success message (non-blocking)
  • Task 2: Backend - Route registration (AC: 3)

    • 2.1 Add POST /declarations/bulk-notify route in web.php with throttle:5,1 middleware
    • 2.2 Name: declarations.bulk-notify
  • Task 3: Frontend - Row selection on declarations Index (AC: 1)

    • 3.1 Add checkbox column to declarations table in resources/js/Pages/declarations/Index.vue
    • 3.2 Track selected declaration IDs in reactive state
    • 3.3 Only allow selection of rows with status en_attente_client
    • 3.4 Add select-all checkbox in header (filtered to eligible rows only)
  • Task 4: Frontend - BulkActionBar component (AC: 1,2)

    • 4.1 Create resources/js/components/declarations/BulkActionBar.vue
    • 4.2 Fixed bottom bar showing "[N] selected" count and "Notify Clients" button
    • 4.3 Only visible when selectedIds.length > 0
    • 4.4 Mobile: render as bottom sheet (responsive)
  • Task 5: Frontend - Confirmation dialog and submission (AC: 2,3)

    • 5.1 Use shadcn-vue Dialog for confirmation (AlertDialog not installed, Dialog used instead)
    • 5.2 Show client count and "Send Notifications" primary button
    • 5.3 POST to declarations.bulk-notify route via Inertia router.post()
    • 5.4 Disable button during processing with spinner
    • 5.5 Show success toast on completion, clear selection state
  • Task 6: Backend - Pass bulk-notify URL and selection eligibility to frontend (AC: 1,5)

    • 6.1 Add bulkNotifyUrl prop from DeclarationController::index() via route('declarations.bulk-notify')
    • 6.2 Add canBulkNotify boolean prop (Owner/Manager always true, Worker true if has assignments)
  • Task 7: Tests (AC: all)

    • 7.1 Feature test: successful bulk notification dispatch for Owner/Manager
    • 7.2 Feature test: Worker can only bulk-notify own assigned declarations
    • 7.3 Feature test: rejects declarations not in en_attente_client status
    • 7.4 Feature test: workspace boundary enforcement (abort 404)
    • 7.5 Feature test: activity log records bulk operation
    • 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)

// 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)