*/ public array $hostIds = []; public function saveDraft(): void { $this->authorize('maintenance.manage'); $this->persist('draft'); } public function publish(): void { $this->authorize('maintenance.manage'); $window = $this->persist('scheduled'); if ($window !== null) { $this->sendAnnouncements($window); } } private function persist(string $state): ?MaintenanceWindow { $data = $this->validate([ 'title' => 'required|string|max:255', 'publicDescription' => 'nullable|string|max:2000', 'internalNotes' => 'nullable|string|max:2000', 'startsAt' => 'required|date', 'endsAt' => 'required|date', 'hostIds' => 'array', 'hostIds.*' => 'integer|exists:hosts,id', ]); $starts = Carbon::parse($data['startsAt']); $ends = Carbon::parse($data['endsAt']); if ($ends->lessThanOrEqualTo($starts)) { $this->addError('endsAt', __('maintenance.end_after_start')); return null; } // A window always needs a host — otherwise a hostless draft can never be // published (there is no edit-hosts action) and is stuck. if (empty($this->hostIds)) { $this->addError('hostIds', __('maintenance.need_host')); return null; } if ($state === 'scheduled' && $ends->isPast()) { $this->addError('endsAt', __('maintenance.end_future')); return null; } // Create the window and attach hosts atomically so a bad id can't leave // an orphaned scheduled window behind. $window = DB::transaction(function () use ($data, $starts, $ends, $state) { $window = MaintenanceWindow::create([ 'title' => $data['title'], 'public_description' => $data['publicDescription'] ?: null, 'internal_notes' => $data['internalNotes'] ?: null, 'starts_at' => $starts, 'ends_at' => $ends, 'state' => $state, 'created_by' => auth()->id(), 'published_at' => $state === 'scheduled' ? now() : null, ]); $window->hosts()->sync(array_map('intval', $data['hostIds'] ?? [])); return $window; }); $this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds'); $this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved')); return $window; } public function publishExisting(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); if ($window === null || $window->state !== 'draft') { return; } if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) { $this->dispatch('notify', message: __('maintenance.need_host')); return; } $window->update(['state' => 'scheduled', 'published_at' => now()]); $this->sendAnnouncements($window); $this->dispatch('notify', message: __('maintenance.published')); } /** * Re-run announcements for a published window. Idempotent (ledger-guarded), * so it only fills gaps — the retry path when a transient queue outage left * some affected customers un-notified. */ public function resend(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); // 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); $this->dispatch('notify', message: __('maintenance.notified')); } public function cancel(string $uuid): void { $this->authorize('maintenance.manage'); $window = MaintenanceWindow::query()->where('uuid', $uuid)->first(); // 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'; $window->update(['state' => 'cancelled', 'cancelled_at' => now()]); // Customers who received an announcement must be told it's cancelled. if ($wasPublished) { $notified = MaintenanceNotification::query() ->where('maintenance_window_id', $window->id) ->where('event', 'announcement') ->whereNotNull('sent_at') // only customers who actually received it ->pluck('customer_id'); foreach (Customer::query()->whereIn('id', $notified)->get() as $customer) { $this->deliverOnce($window, $customer, 'cancelled', new MaintenanceCancelledMail($window, $customer)); } } $this->dispatch('notify', message: __('maintenance.cancelled')); } /** Announce to each affected customer exactly once (ledger-guarded). */ private function sendAnnouncements(MaintenanceWindow $window): void { foreach ($window->affectedCustomers() as $customer) { $this->deliverOnce($window, $customer, 'announcement', new MaintenanceAnnouncementMail($window, $customer)); } } /** * Queue one mail per (window, customer, event). The ledger row is the * idempotency marker; if dispatch itself fails, drop the row so it retries. * sent_at stays null until real delivery — we never claim delivery on dispatch. */ private function deliverOnce(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void { $delivery = MaintenanceNotification::query()->firstOrCreate( ['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. 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; } // Claim the row, then dispatch. If dispatch fails, release the claim // immediately (delete if we created it, else restore the prior attempt // time) so a resend can retry right away instead of waiting out the TTL. $wasNew = $delivery->wasRecentlyCreated; $priorUpdatedAt = $delivery->updated_at; $delivery->forceFill(['updated_at' => now()])->save(); $mail->notificationId = $delivery->id; try { Mail::to($customer->email) ->locale($customer->locale ?: config('app.locale')) ->queue($mail); } catch (\Throwable $e) { $wasNew ? $delivery->delete() : $delivery->forceFill(['updated_at' => $priorUpdatedAt])->save(); throw $e; } } public function render() { $windows = MaintenanceWindow::query() ->withCount('hosts') ->orderByDesc('starts_at') ->get() ->map(fn (MaintenanceWindow $w) => [ 'uuid' => $w->uuid, 'title' => $w->title, 'starts_at' => $w->starts_at, 'ends_at' => $w->ends_at, 'state' => $w->derivedState(), 'hosts' => $w->hosts_count, 'affected' => $w->affectedCustomers()->count(), 'is_draft' => $w->state === 'draft', 'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true), 'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true), ]); return view('livewire.admin.maintenance', [ 'windows' => $windows, 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), ]); } }