diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php new file mode 100644 index 0000000..d591ae6 --- /dev/null +++ b/app/Livewire/Admin/Maintenance.php @@ -0,0 +1,162 @@ + */ + 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(); + + $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; + } + } + + $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', $this->hostIds)); + + $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')); + } + + 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) { + Mail::to($customer->email)->queue(new MaintenanceAnnouncementMail($window, $customer)); + $delivery->update(['sent_at' => now()]); + } + } + } + + 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', + '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(), + ]); + } +} diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php new file mode 100644 index 0000000..a9994d5 --- /dev/null +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -0,0 +1,38 @@ + $this->window->title])); + } + + public function content(): Content + { + return new Content(markdown: 'mail.maintenance-announcement', with: [ + 'title' => $this->window->title, + 'description' => $this->window->public_description, + 'startsAt' => $this->window->starts_at, + 'endsAt' => $this->window->ends_at, + 'name' => $this->customer->name, + ]); + } +} diff --git a/app/Models/Host.php b/app/Models/Host.php index 2a29f52..b6019df 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -6,6 +6,7 @@ use App\Models\Concerns\HasUuid; use App\Provisioning\Contracts\ProvisioningSubject; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; @@ -52,6 +53,11 @@ class Host extends Model implements ProvisioningSubject return $this->hasMany(Instance::class); } + public function maintenanceWindows(): BelongsToMany + { + return $this->belongsToMany(MaintenanceWindow::class); + } + /** Free committable storage: total minus reserve. */ public function freeGb(): int { diff --git a/app/Models/MaintenanceNotification.php b/app/Models/MaintenanceNotification.php new file mode 100644 index 0000000..1ea1802 --- /dev/null +++ b/app/Models/MaintenanceNotification.php @@ -0,0 +1,15 @@ + 'datetime']; + } +} diff --git a/app/Models/MaintenanceWindow.php b/app/Models/MaintenanceWindow.php new file mode 100644 index 0000000..d1b7bd6 --- /dev/null +++ b/app/Models/MaintenanceWindow.php @@ -0,0 +1,110 @@ + */ + use HasFactory, HasUuid; + + /** How far before the start the banner appears (Codex: 72 h). */ + public const DISPLAY_HOURS = 72; + + protected $fillable = [ + 'title', 'public_description', 'internal_notes', 'starts_at', 'ends_at', + 'state', 'created_by', 'published_at', 'cancelled_at', 'cancellation_reason', + ]; + + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'published_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } + + public function hosts(): BelongsToMany + { + return $this->belongsToMany(Host::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(MaintenanceNotification::class); + } + + /** Derived lifecycle state — never stored (Codex). */ + public function derivedState(): string + { + if ($this->state === 'cancelled') { + return 'cancelled'; + } + if ($this->state === 'draft') { + return 'draft'; + } + if (now()->lt($this->starts_at)) { + return 'upcoming'; + } + if (now()->lte($this->ends_at)) { + return 'active'; + } + + return 'completed'; + } + + public function isPublished(): bool + { + return $this->state === 'scheduled'; + } + + /** Service-bearing customers on the assigned hosts (recomputed live). */ + public function affectedCustomers(): Collection + { + $hostIds = $this->hosts()->pluck('hosts.id'); + if ($hostIds->isEmpty()) { + return collect(); + } + + return Customer::query() + ->whereHas('instances', fn (Builder $q) => $q + ->whereIn('host_id', $hostIds) + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])) + ->get(); + } + + /** + * Scheduled windows to show a given customer right now: within the display + * horizon (72 h before start until the end) and touching one of the + * customer's service instances' hosts. + */ + public static function bannerFor(Customer $customer): Collection + { + $hostIds = Instance::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->whereNotNull('host_id') + ->pluck('host_id')->unique(); + + if ($hostIds->isEmpty()) { + return collect(); + } + + return self::query() + ->where('state', 'scheduled') + ->where('ends_at', '>=', now()) + ->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS)) + ->whereHas('hosts', fn (Builder $q) => $q->whereIn('hosts.id', $hostIds)) + ->orderBy('starts_at') + ->get(); + } +} diff --git a/database/factories/MaintenanceWindowFactory.php b/database/factories/MaintenanceWindowFactory.php new file mode 100644 index 0000000..4e38714 --- /dev/null +++ b/database/factories/MaintenanceWindowFactory.php @@ -0,0 +1,34 @@ + */ +class MaintenanceWindowFactory extends Factory +{ + protected $model = MaintenanceWindow::class; + + public function definition(): array + { + return [ + 'title' => 'Netzwerk-Wartung '.$this->faker->city(), + 'public_description' => 'Kurzzeitige Nichtverfügbarkeit möglich.', + 'internal_notes' => null, + 'starts_at' => now()->addDays(2), + 'ends_at' => now()->addDays(2)->addHours(2), + 'state' => 'draft', + ]; + } + + public function scheduled(): static + { + return $this->state(['state' => 'scheduled', 'published_at' => now()]); + } + + public function active(): static + { + return $this->state(['state' => 'scheduled', 'published_at' => now(), 'starts_at' => now()->subHour(), 'ends_at' => now()->addHour()]); + } +} diff --git a/database/migrations/2026_07_25_140001_create_maintenance_windows.php b/database/migrations/2026_07_25_140001_create_maintenance_windows.php new file mode 100644 index 0000000..1f4bd22 --- /dev/null +++ b/database/migrations/2026_07_25_140001_create_maintenance_windows.php @@ -0,0 +1,57 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('title'); + $table->text('public_description')->nullable(); + $table->text('internal_notes')->nullable(); + $table->timestamp('starts_at'); + $table->timestamp('ends_at'); + $table->string('state')->default('draft'); // draft | scheduled | cancelled + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('published_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->string('cancellation_reason')->nullable(); + $table->timestamps(); + }); + + Schema::create('host_maintenance_window', function (Blueprint $table) { + $table->id(); + $table->foreignId('host_id')->constrained()->cascadeOnDelete(); + $table->foreignId('maintenance_window_id')->constrained()->cascadeOnDelete(); + $table->unique(['host_id', 'maintenance_window_id']); + }); + + // Idempotent notification ledger — one row per (window, customer, event). + Schema::create('maintenance_notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('maintenance_window_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('event'); // announcement | cancelled + $table->string('email'); + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + $table->unique(['maintenance_window_id', 'customer_id', 'event'], 'mw_notif_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('maintenance_notifications'); + Schema::dropIfExists('host_maintenance_window'); + Schema::dropIfExists('maintenance_windows'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 551a08e..2dbc668 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -12,6 +12,7 @@ return [ 'hosts' => 'Hosts', 'datacenters' => 'Rechenzentren', 'provisioning' => 'Provisioning', + 'maintenance' => 'Wartungen', 'revenue' => 'Umsatz', 'settings' => 'Einstellungen', ], diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php new file mode 100644 index 0000000..e5e2f93 --- /dev/null +++ b/lang/de/maintenance.php @@ -0,0 +1,50 @@ + 'Wartungen', + 'subtitle' => 'Wartungsfenster anlegen, mehreren Hosts zuweisen und betroffene Kunden benachrichtigen.', + 'empty' => 'Noch keine Wartungsfenster.', + + 'col_window' => 'Fenster', + 'col_when' => 'Zeitraum', + 'col_impact' => 'Betroffen', + 'col_state' => 'Status', + 'col_actions' => 'Aktionen', + 'impact' => ':hosts Host(s) · :customers Kunde(n)', + + 'state_draft' => 'Entwurf', + 'state_upcoming' => 'Geplant', + 'state_active' => 'Aktiv', + 'state_completed' => 'Abgeschlossen', + 'state_cancelled' => 'Storniert', + + 'new_title' => 'Neues Wartungsfenster', + 'field_title' => 'Titel', + 'field_public' => 'Beschreibung für Kunden', + 'field_internal' => 'Interne Notizen', + 'field_start' => 'Beginn', + 'field_end' => 'Ende', + 'field_hosts' => 'Betroffene Hosts', + 'save_draft' => 'Als Entwurf speichern', + 'publish' => 'Veröffentlichen', + 'publish_notify' => 'Veröffentlichen & benachrichtigen', + 'cancel' => 'Stornieren', + 'mail_note' => 'Beim Veröffentlichen erhalten betroffene Kunden eine Ankündigungs-E-Mail (aktuell Log/Mock).', + + 'draft_saved' => 'Entwurf gespeichert.', + 'published' => 'Wartung veröffentlicht — Kunden werden benachrichtigt.', + 'cancelled' => 'Wartung storniert.', + 'end_after_start' => 'Das Ende muss nach dem Beginn liegen.', + 'end_future' => 'Das Ende muss in der Zukunft liegen.', + 'need_host' => 'Bitte mindestens einen Host auswählen.', + + 'banner_upcoming' => 'Geplante Wartung von :start bis :end — es ist nichts zu tun.', + 'banner_active' => 'Wartung läuft — Ihre Cloud ist ggf. vorübergehend nicht erreichbar. Voraussichtliches Ende: :end.', + + 'mail_subject' => 'Geplante Wartung: :title', + 'mail_heading' => 'Geplante Wartung', + 'mail_greeting' => 'Hallo :name,', + 'mail_window' => 'Zeitraum: :start bis :end.', + 'mail_no_action' => 'Es ist nichts zu tun. Ihre Daten bleiben sicher.', + 'mail_signoff' => 'Viele Grüße', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 598a48c..acdf864 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -12,6 +12,7 @@ return [ 'hosts' => 'Hosts', 'datacenters' => 'Datacenters', 'provisioning' => 'Provisioning', + 'maintenance' => 'Maintenance', 'revenue' => 'Revenue', 'settings' => 'Settings', ], diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php new file mode 100644 index 0000000..66b8e4b --- /dev/null +++ b/lang/en/maintenance.php @@ -0,0 +1,50 @@ + 'Maintenance', + 'subtitle' => 'Create maintenance windows, assign them to multiple hosts and notify affected customers.', + 'empty' => 'No maintenance windows yet.', + + 'col_window' => 'Window', + 'col_when' => 'When', + 'col_impact' => 'Affected', + 'col_state' => 'Status', + 'col_actions' => 'Actions', + 'impact' => ':hosts host(s) · :customers customer(s)', + + 'state_draft' => 'Draft', + 'state_upcoming' => 'Scheduled', + 'state_active' => 'Active', + 'state_completed' => 'Completed', + 'state_cancelled' => 'Cancelled', + + 'new_title' => 'New maintenance window', + 'field_title' => 'Title', + 'field_public' => 'Customer-facing description', + 'field_internal' => 'Internal notes', + 'field_start' => 'Start', + 'field_end' => 'End', + 'field_hosts' => 'Affected hosts', + 'save_draft' => 'Save as draft', + 'publish' => 'Publish', + 'publish_notify' => 'Publish & notify', + 'cancel' => 'Cancel', + 'mail_note' => 'On publish, affected customers receive an announcement email (currently log/mock).', + + 'draft_saved' => 'Draft saved.', + 'published' => 'Maintenance published — customers are being notified.', + 'cancelled' => 'Maintenance cancelled.', + 'end_after_start' => 'The end must be after the start.', + 'end_future' => 'The end must be in the future.', + 'need_host' => 'Please select at least one host.', + + 'banner_upcoming' => 'Scheduled maintenance from :start to :end — no action needed.', + 'banner_active' => 'Maintenance in progress — your cloud may be temporarily unavailable. Expected end: :end.', + + 'mail_subject' => 'Scheduled maintenance: :title', + 'mail_heading' => 'Scheduled maintenance', + 'mail_greeting' => 'Hello :name,', + 'mail_window' => 'Window: :start to :end.', + 'mail_no_action' => 'No action is required. Your data stays safe.', + 'mail_signoff' => 'Best regards', +]; diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 127af70..381e0a5 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -35,6 +35,7 @@ ['admin.hosts', 'server', 'hosts'], ['admin.datacenters', 'database', 'datacenters'], ['admin.provisioning', 'activity', 'provisioning'], + ['admin.maintenance', 'alert-triangle', 'maintenance'], ['admin.revenue', 'trending-up', 'revenue'], ] as [$route, $icon, $key]) diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 1b63bd0..792238c 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -11,6 +11,28 @@ @livewireStyles + @php + $maintenanceWindows = collect(); + if (auth()->check()) { + $portalCustomer = \App\Models\Customer::query()->where('user_id', auth()->id())->first() + ?? \App\Models\Customer::query()->where('email', auth()->user()->email)->first(); + if ($portalCustomer) { + $maintenanceWindows = \App\Models\MaintenanceWindow::bannerFor($portalCustomer); + } + } + @endphp + @foreach ($maintenanceWindows as $mw) + @php $isActive = now()->greaterThanOrEqualTo($mw->starts_at) && now()->lessThanOrEqualTo($mw->ends_at); @endphp +
+ + {{ $mw->title }} + + {{ $isActive + ? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')]) + : __('maintenance.banner_upcoming', ['start' => $mw->starts_at->isoFormat('LLL'), 'end' => $mw->ends_at->isoFormat('LT')]) }} + +
+ @endforeach @if (session('impersonator_id')) {{-- Admin impersonation banner — visible on every portal page until the admin returns. --}}
diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php new file mode 100644 index 0000000..037a4db --- /dev/null +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -0,0 +1,105 @@ +
+
+

{{ __('maintenance.title') }}

+

{{ __('maintenance.subtitle') }}

+
+ +
+ {{-- Windows list --}} +
+ @if ($windows->isEmpty()) +

{{ __('maintenance.empty') }}

+ @else +
+ + + + + + + + + + + + @foreach ($windows as $w) + @php $tone = ['draft' => 'info', 'upcoming' => 'provisioning', 'active' => 'warning', 'completed' => 'active', 'cancelled' => 'suspended'][$w['state']] ?? 'info'; @endphp + + + + + + + + @endforeach + +
{{ __('maintenance.col_window') }}{{ __('maintenance.col_when') }}{{ __('maintenance.col_impact') }}{{ __('maintenance.col_state') }}{{ __('maintenance.col_actions') }}
{{ $w['title'] }}{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }}{{ __('maintenance.impact', ['hosts' => $w['hosts'], 'customers' => $w['affected']]) }}{{ __('maintenance.state_'.$w['state']) }} +
+ @if ($w['is_draft']) + + @endif + @if ($w['cancellable']) + + @endif +
+
+
+ @endif +
+ + {{-- Create form --}} +
+

{{ __('maintenance.new_title') }}

+ +
+ + +
+
+ + +
+
+
+ + + @error('startsAt')

{{ $message }}

@enderror +
+
+ + + @error('endsAt')

{{ $message }}

@enderror +
+
+ +
+

{{ __('maintenance.field_hosts') }}

+ @error('hostIds')

{{ $message }}

@enderror +
+ @foreach ($datacenters as $dc) + @php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp + @if ($dcHosts->isNotEmpty()) +
+

{{ $dc->name }}

+
+ @foreach ($dcHosts as $host) + + @endforeach +
+
+ @endif + @endforeach +
+
+ +
+ {{ __('maintenance.save_draft') }} + {{ __('maintenance.publish_notify') }} +
+

{{ __('maintenance.mail_note') }}

+ +
+
diff --git a/resources/views/mail/maintenance-announcement.blade.php b/resources/views/mail/maintenance-announcement.blade.php new file mode 100644 index 0000000..258cec8 --- /dev/null +++ b/resources/views/mail/maintenance-announcement.blade.php @@ -0,0 +1,18 @@ + +# {{ __('maintenance.mail_heading') }} + +{{ __('maintenance.mail_greeting', ['name' => $name]) }} + +**{{ $title }}** + +{{ __('maintenance.mail_window', ['start' => $startsAt->isoFormat('LLLL'), 'end' => $endsAt->isoFormat('LLLL')]) }} + +@if ($description) +{{ $description }} +@endif + +{{ __('maintenance.mail_no_action') }} + +{{ __('maintenance.mail_signoff') }}
+{{ config('app.name') }} +
diff --git a/routes/web.php b/routes/web.php index 4774cbc..6dfaeb6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -63,6 +63,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun // POST so the identity change is CSRF-protected (a GET could be forced cross-site). Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); + Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/settings', Admin\Settings::class)->name('settings'); }); diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php new file mode 100644 index 0000000..1f68eb0 --- /dev/null +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -0,0 +1,75 @@ +create(); + Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active']); + + return $customer; +} + +it('publishes a window and emails affected customers once (idempotent)', function () { + Mail::fake(); + $host = Host::factory()->active()->create(); + $customer = affectedCustomerOn($host); + + Livewire::actingAs(operator('Owner'))->test(Maintenance::class) + ->set('title', 'Netz-Wartung') + ->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) + ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->set('hostIds', [$host->id]) + ->call('publish')->assertHasNoErrors(); + + $window = MaintenanceWindow::query()->where('title', 'Netz-Wartung')->first(); + expect($window)->not->toBeNull()->and($window->state)->toBe('scheduled') + ->and($window->hosts()->count())->toBe(1); + Mail::assertQueued(MaintenanceAnnouncementMail::class, 1); + expect(MaintenanceNotification::query()->where('customer_id', $customer->id)->count())->toBe(1); + + // The ledger's unique (window, customer, event) makes re-announcing a no-op. + expect(fn () => MaintenanceNotification::query()->create([ + 'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email, + ]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class); +}); + +it('requires a host to publish', function () { + Livewire::actingAs(operator('Owner'))->test(Maintenance::class) + ->set('title', 'X') + ->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) + ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->set('hostIds', []) + ->call('publish')->assertHasErrors(['hostIds']); +}); + +it('forbids non-privileged roles from managing maintenance', function () { + Livewire::actingAs(operator('Support'))->test(Maintenance::class) + ->set('title', 'X')->set('startsAt', now()->addDay()->format('Y-m-d\TH:i')) + ->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i')) + ->call('saveDraft')->assertForbidden(); +}); + +it('shows the banner to an affected customer within the horizon', function () { + $host = Host::factory()->active()->create(); + $customer = affectedCustomerOn($host); + $window = MaintenanceWindow::factory()->scheduled()->create(['starts_at' => now()->addDay(), 'ends_at' => now()->addDay()->addHour()]); + $window->hosts()->attach($host->id); + + $banner = MaintenanceWindow::bannerFor($customer); + expect($banner)->toHaveCount(1)->and($banner->first()->id)->toBe($window->id); + + // A customer on another host sees nothing. + $other = affectedCustomerOn(Host::factory()->active()->create()); + expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0); +});