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:
2026-03-26 14:31:36 +01:00
parent 32e11db2b5
commit 1d4f3bcd0f
17 changed files with 1384 additions and 7 deletions

View 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',
]);
}
}

View File

@@ -93,6 +93,8 @@ class DeclarationController extends Controller
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
'canNudge' => ! $isWorker,
'bulkNotifyUrl' => route('declarations.bulk-notify'),
'canBulkNotify' => ! $isWorker,
]);
}

View File

@@ -15,6 +15,7 @@ use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use App\Models\Message;
use App\Models\Workspace;
use App\Observers\DeclarationObserver;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -73,6 +74,8 @@ class DeclarationMessageController extends Controller
$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);
$emailSent = $this->sendEmailForMessage($declaration, $invitation, $message, $body, $type);

View 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'],
];
}
}

View File

@@ -45,6 +45,7 @@ class DeclarationFileRequestMail extends Mailable
'body' => $this->body,
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
'firmName' => $this->declaration->workspace?->name,
]
);
}

View File

@@ -51,6 +51,7 @@ class DeclarationOverdueNotification extends Notification implements ShouldQueue
'declarationTitle' => $this->declaration->title ?? 'Sans titre',
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
'url' => route('declarations.show', $this->declaration),
'firmName' => $this->declaration->workspace?->name,
]);
}
}

View File

@@ -3,11 +3,21 @@
namespace App\Observers;
use App\Enums\DeclarationStatus;
use App\Mail\DeclarationFileRequestMail;
use App\Models\Declaration;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
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.
*
@@ -39,4 +49,95 @@ class DeclarationObserver
$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}");
});
}
}