*/ 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; } if ($state === 'scheduled') { if (empty($this->hostIds)) { $this->addError('hostIds', __('maintenance.need_host')); return null; } if ($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(); if ($window === null || $window->state !== 'scheduled') { 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(); if ($window === null || $window->state === 'cancelled') { return; } $window->update(['state' => 'cancelled', 'cancelled_at' => now()]); $this->dispatch('notify', message: __('maintenance.cancelled')); } /** Send the announcement to each affected customer exactly once (ledger-guarded). */ private function sendAnnouncements(MaintenanceWindow $window): void { foreach ($window->affectedCustomers() as $customer) { $delivery = MaintenanceNotification::query()->firstOrCreate( ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement'], ['email' => $customer->email], ); if ($delivery->wasRecentlyCreated) { // The ledger row is the idempotency marker (queue at most once). // If dispatch itself fails, drop the row so a re-publish retries; // sent_at stays null until real delivery (a MessageSent listener // could stamp it) — we never claim delivery merely on dispatch. try { Mail::to($customer->email) ->locale($customer->locale ?: config('app.locale')) ->queue(new MaintenanceAnnouncementMail($window, $customer)); } catch (\Throwable $e) { $delivery->delete(); 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(), ]); } }