101 lines
4.3 KiB
PHP
101 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Maintenance;
|
|
|
|
use App\Mail\MaintenanceAnnouncementMail;
|
|
use App\Mail\MaintenanceCancelledMail;
|
|
use App\Models\Customer;
|
|
use App\Models\MaintenanceNotification;
|
|
use App\Models\MaintenanceWindow;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Mail\Mailable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Ledger-guarded delivery of maintenance emails. Shared by the admin console and
|
|
* the mail-event listeners so idempotency and the announcement/cancellation
|
|
* pairing are enforced in exactly one place.
|
|
*/
|
|
class MaintenanceNotifier
|
|
{
|
|
/** A pending notification is presumed failed (retryable) after this long. */
|
|
public const RETRY_AFTER_MIN = 60;
|
|
|
|
public function announce(MaintenanceWindow $window): void
|
|
{
|
|
foreach ($window->affectedCustomers() as $customer) {
|
|
$this->deliver($window, $customer, 'announcement', new MaintenanceAnnouncementMail($window, $customer));
|
|
}
|
|
}
|
|
|
|
/** Notify customers who actually received the announcement that it's cancelled. */
|
|
public function notifyCancellation(MaintenanceWindow $window): void
|
|
{
|
|
$ids = MaintenanceNotification::query()
|
|
->where('maintenance_window_id', $window->id)
|
|
->where('event', 'announcement')
|
|
->whereNotNull('sent_at')
|
|
->pluck('customer_id');
|
|
|
|
foreach (Customer::query()->whereIn('id', $ids)->get() as $customer) {
|
|
$this->deliver($window, $customer, 'cancelled', new MaintenanceCancelledMail($window, $customer));
|
|
}
|
|
}
|
|
|
|
/** Queue one mail per (window, customer, event), idempotently and retryably. */
|
|
public function deliver(MaintenanceWindow $window, Customer $customer, string $event, Mailable $mail): void
|
|
{
|
|
$keys = ['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => $event];
|
|
|
|
// firstOrCreate can still race two first-time inserts; the unique index
|
|
// makes one lose — catch it and re-fetch so the operation stays idempotent.
|
|
try {
|
|
$delivery = MaintenanceNotification::query()->firstOrCreate($keys, ['email' => $customer->email]);
|
|
$wasNew = $delivery->wasRecentlyCreated;
|
|
} catch (UniqueConstraintViolationException) {
|
|
$delivery = MaintenanceNotification::query()->where($keys)->firstOrFail();
|
|
$wasNew = false;
|
|
}
|
|
$priorUpdatedAt = $delivery->updated_at;
|
|
|
|
// Claim under a row lock so two workers can't both pass a stale-retry
|
|
// check and double-send. The winner stamps updated_at; the loser re-reads
|
|
// a fresh updated_at and backs off.
|
|
$claimed = DB::transaction(function () use ($delivery, $wasNew) {
|
|
$row = MaintenanceNotification::query()->whereKey($delivery->id)->lockForUpdate()->first();
|
|
if ($row === null || $row->sent_at !== null) {
|
|
return false; // already delivered — final
|
|
}
|
|
if (! $wasNew && $row->updated_at->gt(now()->subMinutes(self::RETRY_AFTER_MIN))) {
|
|
return false; // still pending/in-flight — don't duplicate
|
|
}
|
|
$row->forceFill(['updated_at' => now()])->save();
|
|
|
|
return true;
|
|
});
|
|
if (! $claimed) {
|
|
return;
|
|
}
|
|
|
|
$mail->notificationId = $delivery->id;
|
|
try {
|
|
// Mail::to() alone resolves the DEFAULT mailer. Illuminate\Mail\Mailer::queue()
|
|
// then runs `$view->mailer($this->name)->queue(...)` — that overwrites the
|
|
// mailable's own purpose mailer (set in its constructor, e.g. 'cp_maintenance')
|
|
// with the default's name before the job is even built. Naming the mailer here
|
|
// makes that overwrite a no-op: $this->name on the resolved Mailer is already
|
|
// $mail->mailer, so it writes back the same value.
|
|
Mail::mailer($mail->mailer)
|
|
->to($customer->email)
|
|
->locale($customer->locale ?: config('app.locale'))
|
|
->queue($mail);
|
|
} catch (\Throwable $e) {
|
|
// Release the claim so a resend retries immediately.
|
|
$wasNew ? $delivery->delete() : MaintenanceNotification::query()->whereKey($delivery->id)->update(['updated_at' => $priorUpdatedAt]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|