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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 16:14:01 +02:00
parent 52fa7a34a2
commit 493b81aadf
5 changed files with 56 additions and 21 deletions

View File

@ -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()

View File

@ -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]));

View File

@ -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]));

View File

@ -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()]);
});
}
}

View File

@ -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,
];
});
}
}