feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
6593dae946
commit
52fa7a34a2
|
|
@ -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,33 +150,53 @@ 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) {
|
||||
$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();
|
||||
$this->deliverOnce($window, $customer, 'announcement', new MaintenanceAnnouncementMail($window, $customer));
|
||||
}
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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],
|
||||
);
|
||||
if (! $delivery->wasRecentlyCreated) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Mail::to($customer->email)
|
||||
->locale($customer->locale ?: config('app.locale'))
|
||||
->queue($mail);
|
||||
} catch (\Throwable $e) {
|
||||
$delivery->delete();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue