Files
L-Ami-Fiduciaire/app/Observers/DeclarationObserver.php
Saad Zoubir 1d4f3bcd0f feat: add bulk client notifications and email enhancements with review fixes (Stories 3.4 & 3.5)
Story 3-4: Bulk client notification scheduling — BulkNotificationController,
BulkActionBar component, checkbox selection on declarations index.

Story 3-5: Email notification enhancement — observer-driven email on
en_attente_client, cache invalidation on ferme, workspace branding on
all email templates, 11 feature tests.

Code review fixes:
- Move bulk-notify route above resource wildcard to prevent shadowing
- Add static $suppressEmail flag to prevent observer double-sending
  when DeclarationMessageController already sends the email
- Fix canBulkNotify logic (was granting workers access)
- Add WorkspaceUserRole check to BulkNotifyRequest::authorize()
- Replace firstOrCreate with explicit invitation lookup that syncs
  client email and handles used/expired invitations correctly
- Watch declarations.data instead of current_page to clear selection
  on filter/sort changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:31:36 +01:00

144 lines
4.8 KiB
PHP

<?php
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.
*
* Validates status transitions and auto-archives when status becomes "ferme".
*/
public function updating(Declaration $declaration): void
{
if (! $declaration->isDirty('status')) {
return;
}
$oldStatus = $declaration->getOriginal('status');
$newStatus = $declaration->status;
// Handle both string and enum values
$oldValue = $oldStatus instanceof DeclarationStatus ? $oldStatus->value : (string) $oldStatus;
$newValue = $newStatus instanceof DeclarationStatus ? $newStatus->value : (string) $newStatus;
$allowed = DeclarationStatus::allowedTransitions()[$oldValue] ?? [];
if (! in_array($newValue, $allowed)) {
throw ValidationException::withMessages([
'status' => "Invalid status transition from '{$oldValue}' to '{$newValue}'.",
]);
}
// Auto-archive when status becomes "ferme"
if ($newValue === DeclarationStatus::Ferme) {
$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}");
});
}
}