diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index a9f1c27..be73703 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -20,6 +20,9 @@ use Livewire\Component; #[Layout('layouts.admin')] 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')] public string $title = ''; @@ -188,11 +191,20 @@ class Maintenance extends Component ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event], ['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) { 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::to($customer->email) ->locale($customer->locale ?: config('app.locale')) diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php index f746599..70c8a4d 100644 --- a/tests/Feature/Admin/MaintenanceTest.php +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -42,6 +42,10 @@ it('publishes a window and emails affected customers once (idempotent)', functio expect(fn () => MaintenanceNotification::query()->create([ 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, ]))->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 () {