From 493b81aadf986898a3f2f8e7df6d0057a2c343dc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 16:14:01 +0200 Subject: [PATCH] fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/Maintenance.php | 23 +++++++++++------------ app/Mail/MaintenanceAnnouncementMail.php | 9 +++++++++ app/Mail/MaintenanceCancelledMail.php | 9 +++++++++ app/Providers/AppServiceProvider.php | 14 +++++++++++++- database/factories/HostFactory.php | 22 ++++++++++++++-------- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index 2a4aafa..a9f1c27 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -136,7 +136,8 @@ class Maintenance extends Component { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); - if ($window === null || $window->state !== 'scheduled') { + // Only announce for a window that has not yet ended (derived state). + if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) { return; } $this->sendAnnouncements($window); @@ -147,7 +148,8 @@ class Maintenance extends Component { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); - if ($window === null || $window->state === 'cancelled') { + // Cannot cancel what is already cancelled or has already completed. + if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) { return; } $wasPublished = $window->state === 'scheduled'; @@ -186,18 +188,15 @@ class Maintenance extends Component ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event], ['email' => $customer->email], ); - if (! $delivery->wasRecentlyCreated) { + // 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. + if ($delivery->sent_at !== null) { return; } - try { - Mail::to($customer->email) - ->locale($customer->locale ?: config('app.locale')) - ->queue($mail); - } catch (\Throwable $e) { - $delivery->delete(); - - throw $e; - } + $mail->notificationId = $delivery->id; + Mail::to($customer->email) + ->locale($customer->locale ?: config('app.locale')) + ->queue($mail); } public function render() diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index a9994d5..ef9771c 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -9,17 +9,26 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Mail\Mailables\Headers; use Illuminate\Queue\SerializesModels; class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + /** The ledger row this mail confirms once actually delivered. */ + public ?int $notificationId = null; + public function __construct( public MaintenanceWindow $window, public Customer $customer, ) {} + public function headers(): Headers + { + return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []); + } + public function envelope(): Envelope { return new Envelope(subject: __('maintenance.mail_subject', ['title' => $this->window->title])); diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index a93e8c0..52293c4 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -9,17 +9,26 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Mail\Mailables\Headers; use Illuminate\Queue\SerializesModels; class MaintenanceCancelledMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + /** The ledger row this mail confirms once actually delivered. */ + public ?int $notificationId = null; + public function __construct( public MaintenanceWindow $window, public Customer $customer, ) {} + public function headers(): Headers + { + return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []); + } + public function envelope(): Envelope { return new Envelope(subject: __('maintenance.mail_cancel_subject', ['title' => $this->window->title])); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 45dbf0b..6a6c8d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,9 @@ use App\Services\Traefik\SshTraefikWriter; use App\Services\Traefik\TraefikWriter; use App\Services\Wireguard\LocalWireguardHub; use App\Services\Wireguard\WireguardHub; +use App\Models\MaintenanceNotification; +use Illuminate\Mail\Events\MessageSent; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -42,6 +45,15 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // Stamp a maintenance-notification ledger row as delivered only once the + // mail is actually sent (the X-CP-Notification header carries the id). + // Until then sent_at stays null → the row is a retryable marker. + Event::listen(MessageSent::class, function (MessageSent $event) { + $header = $event->message->getHeaders()->get('X-CP-Notification'); + if ($header === null) { + return; + } + MaintenanceNotification::query()->whereKey((int) $header->getBodyAsString())->update(['sent_at' => now()]); + }); } } diff --git a/database/factories/HostFactory.php b/database/factories/HostFactory.php index ee01b4e..73379d1 100644 --- a/database/factories/HostFactory.php +++ b/database/factories/HostFactory.php @@ -22,13 +22,19 @@ class HostFactory extends Factory public function active(): static { - return $this->state(fn () => [ - 'status' => 'active', - 'wg_ip' => '10.66.0.'.$this->faker->numberBetween(2, 250), - 'total_gb' => 1000, - 'total_ram_mb' => 65536, - 'cpu_cores' => 16, - 'cpu_weight' => 16, - ]); + return $this->state(function () { + // Unique WG IP over a large space — a plain random last octet collides + // (birthday paradox) when a test creates several active hosts. + $n = $this->faker->unique()->numberBetween(1, 16_000_000); + + return [ + 'status' => 'active', + 'wg_ip' => '10.'.(($n >> 16) & 255).'.'.(($n >> 8) & 255).'.'.($n & 255), + 'total_gb' => 1000, + 'total_ram_mb' => 65536, + 'cpu_cores' => 16, + 'cpu_weight' => 16, + ]; + }); } }