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:
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,
|
||||
'canDelete' => ! $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\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);
|
||||
|
||||
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,
|
||||
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
|
||||
'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',
|
||||
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
|
||||
'url' => route('declarations.show', $this->declaration),
|
||||
'firmName' => $this->declaration->workspace?->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user