feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 16:07:21 +02:00
parent 6593dae946
commit 52fa7a34a2
6 changed files with 112 additions and 19 deletions

View File

@ -3,10 +3,13 @@
namespace App\Livewire\Admin;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceNotification;
use App\Models\MaintenanceWindow;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
@ -147,35 +150,55 @@ class Maintenance extends Component
if ($window === null || $window->state === 'cancelled') {
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')
->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'));
}
/** Send the announcement to each affected customer exactly once (ledger-guarded). */
/** 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' => 'announcement'],
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event],
['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.
if (! $delivery->wasRecentlyCreated) {
return;
}
try {
Mail::to($customer->email)
->locale($customer->locale ?: config('app.locale'))
->queue(new MaintenanceAnnouncementMail($window, $customer));
->queue($mail);
} catch (\Throwable $e) {
$delivery->delete();
throw $e;
}
}
}
}
public function render()
{

View File

@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: __('maintenance.mail_cancel_subject', ['title' => $this->window->title]));
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-cancelled', with: [
'title' => $this->window->title,
'name' => $this->customer->name,
]);
}
}

View File

@ -49,4 +49,8 @@ return [
'mail_window' => 'Zeitraum: :start bis :end.',
'mail_no_action' => 'Es ist nichts zu tun. Ihre Daten bleiben sicher.',
'mail_signoff' => 'Viele Grüße',
'mail_cancel_subject' => 'Wartung abgesagt: :title',
'mail_cancel_heading' => 'Wartung abgesagt',
'mail_cancel_body' => 'Die geplante Wartung „:title" wurde abgesagt. Es ist nichts weiter zu tun.',
];

View File

@ -49,4 +49,8 @@ return [
'mail_window' => 'Window: :start to :end.',
'mail_no_action' => 'No action is required. Your data stays safe.',
'mail_signoff' => 'Best regards',
'mail_cancel_subject' => 'Maintenance cancelled: :title',
'mail_cancel_heading' => 'Maintenance cancelled',
'mail_cancel_body' => 'The scheduled maintenance “:title” has been cancelled. Nothing further is needed.',
];

View File

@ -0,0 +1,10 @@
<x-mail::message>
# {{ __('maintenance.mail_cancel_heading') }}
{{ __('maintenance.mail_greeting', ['name' => $name]) }}
{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}
{{ __('maintenance.mail_signoff') }}<br>
{{ config('app.name') }}
</x-mail::message>

View File

@ -44,6 +44,23 @@ it('publishes a window and emails affected customers once (idempotent)', functio
]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class);
});
it('emails a cancellation to customers who were announced', function () {
Mail::fake();
$host = Host::factory()->active()->create();
$customer = affectedCustomerOn($host);
$window = MaintenanceWindow::factory()->scheduled()->create();
$window->hosts()->attach($host->id);
// Simulate the customer having received the announcement.
\App\Models\MaintenanceNotification::create([
'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email,
]);
Livewire::actingAs(operator('Owner'))->test(Maintenance::class)->call('cancel', $window->uuid);
expect($window->fresh()->state)->toBe('cancelled');
Mail::assertQueued(\App\Mail\MaintenanceCancelledMail::class, 1);
});
it('requires a host to publish', function () {
Livewire::actingAs(operator('Owner'))->test(Maintenance::class)
->set('title', 'X')