fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 16:16:15 +02:00
parent 493b81aadf
commit ab5ba2b169
2 changed files with 18 additions and 2 deletions

View File

@ -20,6 +20,9 @@ use Livewire\Component;
#[Layout('layouts.admin')] #[Layout('layouts.admin')]
class Maintenance extends Component class Maintenance extends Component
{ {
/** A pending notification is presumed failed (retryable) after this long. */
private const RETRY_AFTER_MIN = 60;
#[Validate('required|string|max:255')] #[Validate('required|string|max:255')]
public string $title = ''; public string $title = '';
@ -188,11 +191,20 @@ class Maintenance extends Component
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event], ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event],
['email' => $customer->email], ['email' => $customer->email],
); );
// Delivered rows (sent_at set by the MessageSent listener) are final; a
// row that is still unconfirmed (sent_at null) is retryable — re-queue it. // Delivered rows (sent_at set by the MessageSent listener) are final.
if ($delivery->sent_at !== null) { if ($delivery->sent_at !== null) {
return; return;
} }
// Otherwise the row is pending. Re-queue only when this is a fresh claim
// or the previous attempt is stale enough to be presumed failed — so a
// resend while the job is still in flight does not duplicate the email.
// updated_at is the last-attempt marker (touched on every dispatch).
if (! $delivery->wasRecentlyCreated && $delivery->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) {
return;
}
$delivery->forceFill(['updated_at' => now()])->save();
$mail->notificationId = $delivery->id; $mail->notificationId = $delivery->id;
Mail::to($customer->email) Mail::to($customer->email)
->locale($customer->locale ?: config('app.locale')) ->locale($customer->locale ?: config('app.locale'))

View File

@ -42,6 +42,10 @@ it('publishes a window and emails affected customers once (idempotent)', functio
expect(fn () => MaintenanceNotification::query()->create([ expect(fn () => MaintenanceNotification::query()->create([
'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email,
]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class);
// Resending while the first mail is still pending must NOT duplicate.
Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('resend', $window->uuid);
Mail::assertQueued(MaintenanceAnnouncementMail::class, 1);
}); });
it('emails a cancellation to customers who were announced', function () { it('emails a cancellation to customers who were announced', function () {