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)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 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
|
||||||
@@ -35,6 +35,10 @@
|
|||||||
|
|
||||||
generated: 2026-03-11
|
generated: 2026-03-11
|
||||||
last_updated: "2026-03-26"
|
last_updated: "2026-03-26"
|
||||||
|
# Story 3-5 dev completed: 2026-03-26
|
||||||
|
# Story 3-5 contexted: 2026-03-26
|
||||||
|
# Story 3-4 dev started: 2026-03-26
|
||||||
|
# Story 3-4 contexted: 2026-03-26
|
||||||
project: "l'ami fiduciaire"
|
project: "l'ami fiduciaire"
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -72,9 +76,9 @@ development_status:
|
|||||||
epic-3: in-progress
|
epic-3: in-progress
|
||||||
3-1-notification-infrastructure-setup: done
|
3-1-notification-infrastructure-setup: done
|
||||||
3-2-one-click-nudge-system: done
|
3-2-one-click-nudge-system: done
|
||||||
3-3-notification-center-and-bell: review
|
3-3-notification-center-and-bell: done
|
||||||
3-4-bulk-client-notification-scheduling: backlog
|
3-4-bulk-client-notification-scheduling: done
|
||||||
3-5-email-notification-enhancement-for-key-events: backlog
|
3-5-email-notification-enhancement-for-key-events: review
|
||||||
epic-3-retrospective: optional
|
epic-3-retrospective: optional
|
||||||
|
|
||||||
# Epic 4: Bulk Operations, Search & Advanced Filtering
|
# Epic 4: Bulk Operations, Search & Advanced Filtering
|
||||||
|
|||||||
106
app/Http/Controllers/BulkNotificationController.php
Normal file
106
app/Http/Controllers/BulkNotificationController.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Concerns\HasWorkspaceScope;
|
||||||
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Http\Requests\BulkNotifyRequest;
|
||||||
|
use App\Mail\DeclarationFileRequestMail;
|
||||||
|
use App\Models\Declaration;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class BulkNotificationController extends Controller
|
||||||
|
{
|
||||||
|
use HasWorkspaceScope;
|
||||||
|
|
||||||
|
public function store(BulkNotifyRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$workspace = $this->currentWorkspace();
|
||||||
|
$user = $request->user();
|
||||||
|
$workspaceUser = $user->currentWorkspaceUser();
|
||||||
|
|
||||||
|
$declarations = Declaration::where('workspace_id', $workspace->id)
|
||||||
|
->forUser($user, $workspaceUser)
|
||||||
|
->where('status', DeclarationStatus::EnAttenteClient)
|
||||||
|
->whereIn('id', $request->validated('declaration_ids'))
|
||||||
|
->with('client')
|
||||||
|
->get()
|
||||||
|
->filter(fn (Declaration $d) => $d->client !== null);
|
||||||
|
|
||||||
|
if ($declarations->isEmpty()) {
|
||||||
|
return back()->with('flash', [
|
||||||
|
'type' => 'warning',
|
||||||
|
'message' => 'Aucune déclaration éligible trouvée.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB transaction for invitation creation/update, collect mail data for queuing after commit
|
||||||
|
$mailJobs = DB::transaction(function () use ($declarations) {
|
||||||
|
$jobs = [];
|
||||||
|
|
||||||
|
foreach ($declarations as $declaration) {
|
||||||
|
$clientEmail = $declaration->client->contact_email;
|
||||||
|
|
||||||
|
$invitation = $declaration->invitations()
|
||||||
|
->whereNull('used_at')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($invitation && $invitation->isValid()) {
|
||||||
|
if ($invitation->email !== $clientEmail) {
|
||||||
|
$invitation->update(['email' => $clientEmail]);
|
||||||
|
$invitation->refresh();
|
||||||
|
}
|
||||||
|
} elseif ($invitation && ! $invitation->isValid()) {
|
||||||
|
$invitation->update([
|
||||||
|
'email' => $clientEmail,
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
]);
|
||||||
|
$invitation->refresh();
|
||||||
|
} else {
|
||||||
|
$invitation = $declaration->invitations()->create([
|
||||||
|
'email' => $clientEmail,
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = 'Nous vous invitons à déposer les documents complémentaires pour votre déclaration "'
|
||||||
|
. $declaration->title . '".';
|
||||||
|
|
||||||
|
$jobs[] = [
|
||||||
|
'email' => $declaration->client->contact_email,
|
||||||
|
'mailable' => new DeclarationFileRequestMail($declaration, $invitation, $body),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jobs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue emails outside transaction (Redis is not transactional with MySQL)
|
||||||
|
foreach ($mailJobs as $job) {
|
||||||
|
Mail::to($job['email'])->queue($job['mailable']);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity()
|
||||||
|
->performedOn($workspace)
|
||||||
|
->causedBy($user)
|
||||||
|
->withProperties([
|
||||||
|
'count' => $declarations->count(),
|
||||||
|
'declaration_ids' => $declarations->pluck('id')->all(),
|
||||||
|
])
|
||||||
|
->log('bulk_client_notification');
|
||||||
|
|
||||||
|
// Invalidate notification caches for workspace users
|
||||||
|
$workspace->users->each(function ($wsUser) use ($workspace) {
|
||||||
|
Cache::forget("user:{$wsUser->id}:workspace:{$workspace->id}:unread_notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
return back()->with('flash', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => $declarations->count() . ' notifications envoyées',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,8 @@ class DeclarationController extends Controller
|
|||||||
'canEdit' => ! $isWorker,
|
'canEdit' => ! $isWorker,
|
||||||
'canDelete' => ! $isWorker,
|
'canDelete' => ! $isWorker,
|
||||||
'canNudge' => ! $isWorker,
|
'canNudge' => ! $isWorker,
|
||||||
|
'bulkNotifyUrl' => route('declarations.bulk-notify'),
|
||||||
|
'canBulkNotify' => ! $isWorker,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Models\Declaration;
|
|||||||
use App\Models\DeclarationInvitation;
|
use App\Models\DeclarationInvitation;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Observers\DeclarationObserver;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -73,6 +74,8 @@ class DeclarationMessageController extends Controller
|
|||||||
$message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]);
|
$message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppress observer email — this controller sends its own email below
|
||||||
|
DeclarationObserver::$suppressEmail = true;
|
||||||
$this->updateDeclarationStatusAndConfirmation($declaration, $type, $mediaIds);
|
$this->updateDeclarationStatusAndConfirmation($declaration, $type, $mediaIds);
|
||||||
|
|
||||||
$emailSent = $this->sendEmailForMessage($declaration, $invitation, $message, $body, $type);
|
$emailSent = $this->sendEmailForMessage($declaration, $invitation, $message, $body, $type);
|
||||||
|
|||||||
38
app/Http/Requests/BulkNotifyRequest.php
Normal file
38
app/Http/Requests/BulkNotifyRequest.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\WorkspaceUserRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class BulkNotifyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceUser = $user->currentWorkspaceUser();
|
||||||
|
|
||||||
|
return ! $workspaceUser->role->is(WorkspaceUserRole::Worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'declaration_ids' => ['required', 'array', 'min:1', 'max:100'],
|
||||||
|
'declaration_ids.*' => ['integer'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ class DeclarationFileRequestMail extends Mailable
|
|||||||
'body' => $this->body,
|
'body' => $this->body,
|
||||||
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
|
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
|
||||||
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
|
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
|
||||||
|
'firmName' => $this->declaration->workspace?->name,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class DeclarationOverdueNotification extends Notification implements ShouldQueue
|
|||||||
'declarationTitle' => $this->declaration->title ?? 'Sans titre',
|
'declarationTitle' => $this->declaration->title ?? 'Sans titre',
|
||||||
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
|
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
|
||||||
'url' => route('declarations.show', $this->declaration),
|
'url' => route('declarations.show', $this->declaration),
|
||||||
|
'firmName' => $this->declaration->workspace?->name,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,21 @@
|
|||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
use App\Enums\DeclarationStatus;
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Mail\DeclarationFileRequestMail;
|
||||||
use App\Models\Declaration;
|
use App\Models\Declaration;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class DeclarationObserver
|
class DeclarationObserver
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* When true, the observer skips sending the client email on en_attente_client.
|
||||||
|
* Set this before updating status when the caller already sends the email.
|
||||||
|
*/
|
||||||
|
public static bool $suppressEmail = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the Declaration "updating" event.
|
* Handle the Declaration "updating" event.
|
||||||
*
|
*
|
||||||
@@ -39,4 +49,95 @@ class DeclarationObserver
|
|||||||
$declaration->archived_at = now();
|
$declaration->archived_at = now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the Declaration "updated" event.
|
||||||
|
*
|
||||||
|
* Dispatches side effects on status transitions:
|
||||||
|
* - en_attente_client: queues client email via DeclarationFileRequestMail
|
||||||
|
* - ferme: invalidates dashboard cache for all workspace users
|
||||||
|
*/
|
||||||
|
public function updated(Declaration $declaration): void
|
||||||
|
{
|
||||||
|
if (! $declaration->wasChanged('status')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = $declaration->status instanceof DeclarationStatus
|
||||||
|
? $declaration->status->value
|
||||||
|
: (string) $declaration->status;
|
||||||
|
|
||||||
|
if ($newStatus === DeclarationStatus::EnAttenteClient && ! static::$suppressEmail) {
|
||||||
|
$this->sendClientFileRequestEmail($declaration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset suppression flag after each update
|
||||||
|
static::$suppressEmail = false;
|
||||||
|
|
||||||
|
if ($newStatus === DeclarationStatus::Ferme) {
|
||||||
|
$this->invalidateDashboardCache($declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendClientFileRequestEmail(Declaration $declaration): void
|
||||||
|
{
|
||||||
|
$declaration->loadMissing('client');
|
||||||
|
$client = $declaration->client;
|
||||||
|
|
||||||
|
if (! $client || ! $client->contact_email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailJob = DB::transaction(function () use ($declaration, $client) {
|
||||||
|
$invitation = $declaration->invitations()
|
||||||
|
->whereNull('used_at')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($invitation && $invitation->isValid()) {
|
||||||
|
// Reuse valid unused invitation, but sync email with current client
|
||||||
|
if ($invitation->email !== $client->contact_email) {
|
||||||
|
$invitation->update(['email' => $client->contact_email]);
|
||||||
|
$invitation->refresh();
|
||||||
|
}
|
||||||
|
} elseif ($invitation && ! $invitation->isValid()) {
|
||||||
|
// Expired but unused — renew it
|
||||||
|
$invitation->update([
|
||||||
|
'email' => $client->contact_email,
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
]);
|
||||||
|
$invitation->refresh();
|
||||||
|
} else {
|
||||||
|
// No unused invitation exists (all used or none) — create fresh
|
||||||
|
$invitation = $declaration->invitations()->create([
|
||||||
|
'email' => $client->contact_email,
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = 'Nous vous invitons à déposer les documents complémentaires pour votre déclaration "'
|
||||||
|
. $declaration->title . '".';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'email' => $client->contact_email,
|
||||||
|
'mailable' => new DeclarationFileRequestMail($declaration, $invitation, $body),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
Mail::to($mailJob['email'])->queue($mailJob['mailable']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateDashboardCache(Declaration $declaration): void
|
||||||
|
{
|
||||||
|
$declaration->loadMissing('workspace.users');
|
||||||
|
$workspace = $declaration->workspace;
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace->users->each(function ($user) use ($workspace) {
|
||||||
|
Cache::forget("dashboard:{$workspace->id}:{$user->id}");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
resources/js/components/declarations/BulkActionBar.vue
Normal file
118
resources/js/components/declarations/BulkActionBar.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import { Loader2, Mail, X } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedIds: number[];
|
||||||
|
bulkNotifyUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Emits = {
|
||||||
|
clear: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const showConfirmDialog = ref(false);
|
||||||
|
const processing = ref(false);
|
||||||
|
|
||||||
|
function sendNotifications() {
|
||||||
|
processing.value = true;
|
||||||
|
router.post(
|
||||||
|
props.bulkNotifyUrl,
|
||||||
|
{ declaration_ids: props.selectedIds },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showConfirmDialog.value = false;
|
||||||
|
processing.value = false;
|
||||||
|
emit('clear');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-full opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-full opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedIds.length > 0"
|
||||||
|
class="fixed inset-x-0 bottom-0 z-50 border-t bg-background p-4 shadow-lg md:p-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto flex max-w-7xl flex-col items-center gap-3 md:flex-row md:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ selectedIds.length }} selectionne{{
|
||||||
|
selectedIds.length > 1 ? 's' : ''
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('clear')"
|
||||||
|
>
|
||||||
|
<X class="mr-1 h-4 w-4" />
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button @click="showConfirmDialog = true">
|
||||||
|
<Mail class="mr-2 h-4 w-4" />
|
||||||
|
Notifier les clients
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Dialog v-model:open="showConfirmDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmer l'envoi</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Vous allez envoyer une notification par email a
|
||||||
|
{{ selectedIds.length }} client{{
|
||||||
|
selectedIds.length > 1 ? 's' : ''
|
||||||
|
}}
|
||||||
|
pour demander les documents manquants.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
@click="showConfirmDialog = false"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="processing" @click="sendNotifications">
|
||||||
|
<Loader2
|
||||||
|
v-if="processing"
|
||||||
|
class="mr-2 h-4 w-4 animate-spin"
|
||||||
|
/>
|
||||||
|
Envoyer les notifications
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
import { Head, Link, router } from '@inertiajs/vue3';
|
||||||
import { FolderOpen } from 'lucide-vue-next';
|
import { FolderOpen } from 'lucide-vue-next';
|
||||||
|
import BulkActionBar from '@/components/declarations/BulkActionBar.vue';
|
||||||
import NudgePopover from '@/components/declarations/NudgePopover.vue';
|
import NudgePopover from '@/components/declarations/NudgePopover.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import Pagination from '@/components/Pagination.vue';
|
import Pagination from '@/components/Pagination.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
|
||||||
type Declaration = {
|
type Declaration = {
|
||||||
@@ -44,10 +47,61 @@ type Props = {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canNudge: boolean;
|
canNudge: boolean;
|
||||||
|
bulkNotifyUrl?: string;
|
||||||
|
canBulkNotify?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const selectedIds = ref<number[]>([]);
|
||||||
|
|
||||||
|
watch(() => props.declarations.data, () => {
|
||||||
|
selectedIds.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleDeclarations = computed(() =>
|
||||||
|
props.declarations.data.filter(
|
||||||
|
(d) => d.status === 'en_attente_client',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allEligibleSelected = computed(
|
||||||
|
() =>
|
||||||
|
eligibleDeclarations.value.length > 0 &&
|
||||||
|
eligibleDeclarations.value.every((d) =>
|
||||||
|
selectedIds.value.includes(d.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleSelectAll(checked: boolean | 'indeterminate') {
|
||||||
|
if (checked === true) {
|
||||||
|
const eligibleIds = eligibleDeclarations.value.map((d) => d.id);
|
||||||
|
const merged = new Set([...selectedIds.value, ...eligibleIds]);
|
||||||
|
selectedIds.value = [...merged];
|
||||||
|
} else {
|
||||||
|
const eligibleIds = new Set(
|
||||||
|
eligibleDeclarations.value.map((d) => d.id),
|
||||||
|
);
|
||||||
|
selectedIds.value = selectedIds.value.filter(
|
||||||
|
(id) => !eligibleIds.has(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(id: number, checked: boolean | 'indeterminate') {
|
||||||
|
if (checked === true) {
|
||||||
|
if (!selectedIds.value.includes(id)) {
|
||||||
|
selectedIds.value = [...selectedIds.value, id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
function destroy(declaration: Declaration) {
|
function destroy(declaration: Declaration) {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
@@ -80,6 +134,8 @@ const statusLabels: Record<string, string> = {
|
|||||||
closed: 'Clôturé',
|
closed: 'Clôturé',
|
||||||
cancelled: 'Annulé',
|
cancelled: 'Annulé',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columnCount = computed(() => (props.canBulkNotify ? 7 : 6));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -107,6 +163,18 @@ const statusLabels: Record<string, string> = {
|
|||||||
class="border-b border-sidebar-border/70 bg-muted/50"
|
class="border-b border-sidebar-border/70 bg-muted/50"
|
||||||
>
|
>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th
|
||||||
|
v-if="props.canBulkNotify"
|
||||||
|
class="h-10 w-10 px-4 text-center align-middle"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="allEligibleSelected"
|
||||||
|
:disabled="
|
||||||
|
eligibleDeclarations.length === 0
|
||||||
|
"
|
||||||
|
@update:checked="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
class="h-10 px-4 text-left align-middle font-medium"
|
class="h-10 px-4 text-left align-middle font-medium"
|
||||||
>
|
>
|
||||||
@@ -145,6 +213,26 @@ const statusLabels: Record<string, string> = {
|
|||||||
:key="declaration.id"
|
:key="declaration.id"
|
||||||
class="border-b border-sidebar-border/50 last:border-0"
|
class="border-b border-sidebar-border/50 last:border-0"
|
||||||
>
|
>
|
||||||
|
<td
|
||||||
|
v-if="props.canBulkNotify"
|
||||||
|
class="px-4 py-3 text-center"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
v-if="
|
||||||
|
declaration.status ===
|
||||||
|
'en_attente_client'
|
||||||
|
"
|
||||||
|
:checked="
|
||||||
|
selectedIds.includes(
|
||||||
|
declaration.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@update:checked="
|
||||||
|
(v: boolean | 'indeterminate') =>
|
||||||
|
toggleRow(declaration.id, v)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 font-medium">
|
<td class="px-4 py-3 font-medium">
|
||||||
<Link
|
<Link
|
||||||
:href="declaration.showUrl"
|
:href="declaration.showUrl"
|
||||||
@@ -212,7 +300,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!declarations.data.length">
|
<tr v-if="!declarations.data.length">
|
||||||
<td
|
<td
|
||||||
colspan="6"
|
:colspan="columnCount"
|
||||||
class="px-4 py-8 text-center text-muted-foreground"
|
class="px-4 py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -246,5 +334,12 @@ const statusLabels: Record<string, string> = {
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BulkActionBar
|
||||||
|
v-if="props.canBulkNotify && props.bulkNotifyUrl"
|
||||||
|
:selected-ids="selectedIds"
|
||||||
|
:bulk-notify-url="props.bulkNotifyUrl"
|
||||||
|
@clear="clearSelection"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<x-mail::message>
|
<x-mail::message>
|
||||||
# Documents complémentaires demandés
|
# {{ $firmName ?? 'Votre cabinet' }} — Documents complémentaires demandés
|
||||||
|
|
||||||
Bonjour,
|
Bonjour,
|
||||||
|
|
||||||
Votre cabinet comptable vous demande des documents complémentaires pour le dossier **{{ $declarationTitle }}**.
|
{{ $firmName ?? 'Votre cabinet comptable' }} vous demande des documents complémentaires pour le dossier **{{ $declarationTitle }}**.
|
||||||
|
|
||||||
{{ $body }}
|
{{ $body }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<x-mail::message>
|
<x-mail::message>
|
||||||
# Déclaration en retard
|
# {{ $firmName ?? 'Votre cabinet' }} — Déclaration en retard
|
||||||
|
|
||||||
Bonjour,
|
Bonjour,
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
Route::middleware('workspace')->group(function () {
|
Route::middleware('workspace')->group(function () {
|
||||||
Route::resource('clients', \App\Http\Controllers\ClientController::class);
|
Route::resource('clients', \App\Http\Controllers\ClientController::class);
|
||||||
|
Route::post('declarations/bulk-notify', [\App\Http\Controllers\BulkNotificationController::class, 'store'])
|
||||||
|
->middleware('throttle:5,1')
|
||||||
|
->name('declarations.bulk-notify');
|
||||||
Route::resource('declarations', \App\Http\Controllers\DeclarationController::class);
|
Route::resource('declarations', \App\Http\Controllers\DeclarationController::class);
|
||||||
Route::post('declarations/{declaration}/messages', [\App\Http\Controllers\DeclarationMessageController::class, 'store'])
|
Route::post('declarations/{declaration}/messages', [\App\Http\Controllers\DeclarationMessageController::class, 'store'])
|
||||||
->name('declarations.messages.store');
|
->name('declarations.messages.store');
|
||||||
|
|||||||
211
tests/Feature/Notifications/BulkNotificationTest.php
Normal file
211
tests/Feature/Notifications/BulkNotificationTest.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Mail\DeclarationFileRequestMail;
|
||||||
|
use App\Models\Declaration;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
function setupBulkNotifyTest(string $role = 'owner', int $declarationCount = 3): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$workspace->users()->attach($user->id, ['role' => $role, 'permissions' => []]);
|
||||||
|
|
||||||
|
$declarations = Declaration::factory()
|
||||||
|
->forWorkspace($workspace)
|
||||||
|
->count($declarationCount)
|
||||||
|
->create(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
session(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
return [$user, $workspace, $declarations];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC1,3,4: Owner/Manager can bulk-notify clients ──────
|
||||||
|
|
||||||
|
test('owner can bulk-notify clients — emails queued, activity logged, success flash', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner');
|
||||||
|
|
||||||
|
$ids = $declarations->pluck('id')->all();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('flash', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => '3 notifications envoyées',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager can bulk-notify clients — same behavior as owner', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('manager');
|
||||||
|
|
||||||
|
$ids = $declarations->pluck('id')->all();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('flash', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => '3 notifications envoyées',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC5: Worker cannot access bulk-notify (consistent with canEdit, canDelete, canNudge) ──
|
||||||
|
|
||||||
|
test('worker is forbidden from bulk-notify endpoint', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$worker = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$workspace->users()->attach($worker->id, ['role' => 'worker', 'permissions' => []]);
|
||||||
|
|
||||||
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||||
|
'status' => DeclarationStatus::EnAttenteClient,
|
||||||
|
'assigned_to' => $worker->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($worker)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => [$declaration->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC3: Rejects declarations not in en_attente_client status ──
|
||||||
|
|
||||||
|
test('rejects declarations not in en_attente_client status', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 2);
|
||||||
|
|
||||||
|
// Change one declaration to a different status
|
||||||
|
$declarations[0]->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
|
||||||
|
$ids = $declarations->pluck('id')->all();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('flash', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => '1 notifications envoyées',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only the one remaining en_attente_client declaration should get an email
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC4: Workspace boundary enforcement ──
|
||||||
|
|
||||||
|
test('workspace boundary enforcement — declarations from other workspace ignored', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 1);
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
$otherDeclaration = Declaration::factory()->forWorkspace($otherWorkspace)->create([
|
||||||
|
'status' => DeclarationStatus::EnAttenteClient,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => [$declarations[0]->id, $otherDeclaration->id],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
|
||||||
|
// Only 1 email (from own workspace), the other is filtered out
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC4: Activity log records bulk operation ──
|
||||||
|
|
||||||
|
test('activity log records bulk operation with count and actor', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 2);
|
||||||
|
|
||||||
|
$ids = $declarations->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activity = Activity::query()
|
||||||
|
->where('subject_type', Workspace::class)
|
||||||
|
->where('subject_id', $workspace->id)
|
||||||
|
->where('causer_id', $user->id)
|
||||||
|
->where('description', 'bulk_client_notification')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($activity)->not->toBeNull();
|
||||||
|
expect($activity->properties['count'])->toBe(2);
|
||||||
|
expect($activity->properties['declaration_ids'])->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Throttle middleware prevents abuse ──
|
||||||
|
|
||||||
|
test('throttle middleware prevents abuse — 429 after 5 requests', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declarations] = setupBulkNotifyTest('owner', 1);
|
||||||
|
$ids = $declarations->pluck('id')->all();
|
||||||
|
|
||||||
|
// Send 5 requests (within the throttle limit)
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th request should be throttled
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => $ids,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unauthenticated user cannot bulk-notify ──
|
||||||
|
|
||||||
|
test('unauthenticated user cannot bulk-notify — redirected to login', function () {
|
||||||
|
$response = $this->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => [1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation: empty declaration_ids rejected ──
|
||||||
|
|
||||||
|
test('validation rejects empty declaration_ids', function () {
|
||||||
|
[$user, $workspace] = setupBulkNotifyTest('owner', 0);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('declarations.bulk-notify'), [
|
||||||
|
'declaration_ids' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('declaration_ids');
|
||||||
|
});
|
||||||
195
tests/Feature/Notifications/EmailNotificationTest.php
Normal file
195
tests/Feature/Notifications/EmailNotificationTest.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Mail\DeclarationFileRequestMail;
|
||||||
|
use App\Mail\NudgeNotificationMail;
|
||||||
|
use App\Models\Declaration;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Notifications\DocumentUploadedNotification;
|
||||||
|
use App\Notifications\NudgeNotification;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
function setupEmailNotificationTest(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||||
|
|
||||||
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
return [$user, $workspace, $declaration];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AC3: Status change to en_attente_client triggers client email ──
|
||||||
|
|
||||||
|
test('status change to en_attente_client queues client email', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, function ($mail) use ($declaration) {
|
||||||
|
return $mail->declaration->id === $declaration->id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status change to en_attente_client creates declaration invitation', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
expect($declaration->invitations()->count())->toBe(1);
|
||||||
|
expect($declaration->invitations()->first()->email)->toBe($declaration->client->contact_email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status change to en_attente_client skips email when client has no email', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||||
|
|
||||||
|
$declaration->client->update(['contact_email' => null]);
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC4: Status change to ferme invalidates dashboard cache ──
|
||||||
|
|
||||||
|
test('status change to ferme invalidates dashboard cache', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||||
|
|
||||||
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||||
|
'status' => DeclarationStatus::Termine,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||||
|
Cache::put($cacheKey, 'cached-data', 300);
|
||||||
|
|
||||||
|
expect(Cache::has($cacheKey))->toBeTrue();
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||||
|
|
||||||
|
expect(Cache::has($cacheKey))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC6: Status change to en_cours does NOT trigger any email ──
|
||||||
|
|
||||||
|
test('status change to en_cours does not trigger any email', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||||
|
|
||||||
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||||
|
'status' => DeclarationStatus::Created,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC2: DocumentUploadedNotification has no mail channel ──
|
||||||
|
|
||||||
|
test('document uploaded notification has no mail channel', function () {
|
||||||
|
$declaration = Declaration::factory()->create();
|
||||||
|
$notification = new DocumentUploadedNotification($declaration, 1);
|
||||||
|
|
||||||
|
$channels = $notification->via(new stdClass);
|
||||||
|
|
||||||
|
expect($channels)->toBe(['database']);
|
||||||
|
expect($channels)->not->toContain('mail');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC1: Nudge email contains required fields ──
|
||||||
|
|
||||||
|
test('nudge email contains required fields', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create(['name' => 'Cabinet Test']);
|
||||||
|
$workspace->users()->attach($user->id, ['role' => 'owner', 'permissions' => []]);
|
||||||
|
|
||||||
|
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mail = new NudgeNotificationMail($declaration, $user);
|
||||||
|
|
||||||
|
$rendered = $mail->render();
|
||||||
|
|
||||||
|
expect($rendered)->toContain($user->name);
|
||||||
|
expect($rendered)->toContain('Voir la déclaration');
|
||||||
|
expect($rendered)->toContain('Cabinet Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC1: Nudge notification sends via database and mail channels ──
|
||||||
|
|
||||||
|
test('nudge notification sends via database and mail channels', function () {
|
||||||
|
$declaration = Declaration::factory()->create();
|
||||||
|
$sender = User::factory()->create();
|
||||||
|
$notification = new NudgeNotification($declaration, $sender);
|
||||||
|
|
||||||
|
$channels = $notification->via(new stdClass);
|
||||||
|
|
||||||
|
expect($channels)->toBe(['database', 'mail']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC6: Workspace scoping on notification dispatches ──
|
||||||
|
|
||||||
|
test('status change email is scoped to the correct workspace client', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
[$user, $workspace, $declaration] = setupEmailNotificationTest();
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
$otherDeclaration = Declaration::factory()->forWorkspace($otherWorkspace)->create([
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only our declaration should trigger email
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
$otherDeclaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, 2);
|
||||||
|
|
||||||
|
// Each email goes to the correct client
|
||||||
|
Mail::assertQueued(DeclarationFileRequestMail::class, function ($mail) use ($declaration) {
|
||||||
|
return $mail->declaration->id === $declaration->id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AC5: Failed email retries — verify $tries and $backoff properties ──
|
||||||
|
|
||||||
|
test('nudge notification has retry configuration', function () {
|
||||||
|
$declaration = Declaration::factory()->create();
|
||||||
|
$sender = User::factory()->create();
|
||||||
|
$notification = new NudgeNotification($declaration, $sender);
|
||||||
|
|
||||||
|
expect($notification->tries)->toBe(3);
|
||||||
|
expect($notification->backoff)->toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declaration overdue notification has retry configuration', function () {
|
||||||
|
$declaration = Declaration::factory()->create();
|
||||||
|
$notification = new \App\Notifications\DeclarationOverdueNotification($declaration);
|
||||||
|
|
||||||
|
expect($notification->tries)->toBe(3);
|
||||||
|
expect($notification->backoff)->toBe(60);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user