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:
@@ -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