feat(notification): email event date changes

This commit is contained in:
ncoronel 2026-09-14 08:53:51 -03:00
parent 205d77bc0b
commit 756f4dad0a
10 changed files with 601 additions and 0 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateRescheduledEmails implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(EventDateRescheduled $event): void
{
app(NotificationMailService::class)->sendEventDateRescheduled(
$event->tenantCode,
$event->sourceEventDateId,
$event->destinationEventDateId,
$event->previousDate,
$event->newDate,
$event->purchaseTickets,
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateSuspendedEmails implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(EventDateSuspended $event): void
{
app(NotificationMailService::class)->sendEventDateSuspended(
$event->tenantCode,
$event->eventDateId,
$event->date,
$event->purchaseTickets,
);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Domains\Notification\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'notification_key',
'notification_type',
'tenant_code',
'event_date_id',
'destination_event_date_id',
'purchase_id',
'sent_at',
])]
class EventDateNotificationDelivery extends Model
{
protected function casts(): array
{
return [
'event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'purchase_id' => 'integer',
'sent_at' => 'datetime',
];
}
}

View File

@ -6,11 +6,13 @@ use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Models\EventDateNotificationDelivery;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
@ -184,6 +186,277 @@ class NotificationMailService
});
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateRescheduled(
string $tenantCode,
int $sourceEventDateId,
int $destinationEventDateId,
string $previousDate,
string $newDate,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $sourceEventDateId,
'destination_event_date_id' => $destinationEventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'no_longer_active_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}";
if (! $this->claimDelivery(
$deliveryKey,
'event_date_rescheduled',
$tenantCode,
$sourceEventDateId,
$destinationEventDateId,
$purchaseId,
)) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'already_sent',
]));
continue;
}
try {
$this->sendLogged('event_date_rescheduled', $context, function () use (
$tenantCode,
$purchase,
$recipient,
$previousDate,
$newDate,
$tickets,
): array {
$brand = $purchase->tenant->websiteType ?? $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Tu evento fue reprogramado - Compra #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets'
))->render(),
$brand,
);
return ['ticket_count' => $tickets->count()];
});
$this->markDeliverySent($deliveryKey);
} catch (Throwable $exception) {
$this->releaseDelivery($deliveryKey);
throw $exception;
}
}
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateSuspended(
string $tenantCode,
int $eventDateId,
string $date,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $eventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => in_array($ticket->status, [
Ticket::STATUS_ACTIVE,
Ticket::STATUS_DISABLED,
], true))
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'no_longer_relevant_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}";
if (! $this->claimDelivery(
$deliveryKey,
'event_date_suspended',
$tenantCode,
$eventDateId,
null,
$purchaseId,
)) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'already_sent',
]));
continue;
}
$disabledTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED)
->values();
$activeTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE)
->values();
try {
$this->sendLogged('event_date_suspended', $context, function () use (
$tenantCode,
$purchase,
$recipient,
$date,
$disabledTickets,
$activeTickets,
): array {
$brand = $purchase->tenant->websiteType ?? $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Una fecha de tu evento fue suspendida - Compra #{$purchase->getKey()}",
view('mail.notifications.event-date-suspended', compact(
'purchase', 'date', 'disabledTickets', 'activeTickets'
))->render(),
$brand,
);
return [
'ticket_count' => $disabledTickets->count() + $activeTickets->count(),
'disabled_ticket_ids' => $disabledTickets->modelKeys(),
'active_ticket_ids' => $activeTickets->modelKeys(),
];
});
$this->markDeliverySent($deliveryKey);
} catch (Throwable $exception) {
$this->releaseDelivery($deliveryKey);
throw $exception;
}
}
}
private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenantCode)
->with(['tenant.websiteType', 'user'])
->find($purchaseId);
}
private function claimDelivery(
string $deliveryKey,
string $type,
string $tenantCode,
int $eventDateId,
?int $destinationEventDateId,
int $purchaseId,
): bool {
return EventDateNotificationDelivery::query()->insertOrIgnore([
'notification_key' => $deliveryKey,
'notification_type' => $type,
'tenant_code' => $tenantCode,
'event_date_id' => $eventDateId,
'destination_event_date_id' => $destinationEventDateId,
'purchase_id' => $purchaseId,
'created_at' => now(),
'updated_at' => now(),
]) === 1;
}
private function markDeliverySent(string $deliveryKey): void
{
EventDateNotificationDelivery::query()
->where('notification_key', $deliveryKey)
->update(['sent_at' => now()]);
}
private function releaseDelivery(string $deliveryKey): void
{
EventDateNotificationDelivery::query()
->where('notification_key', $deliveryKey)
->delete();
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);

View File

@ -2,10 +2,14 @@
namespace App\Providers;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Policies\IntegrationPolicy;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails;
use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
use App\Domains\Notification\Listeners\SendWelcomeEmail;
@ -40,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
);
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
Event::listen(EventDateRescheduled::class, SendEventDateRescheduledEmails::class);
Event::listen(EventDateSuspended::class, SendEventDateSuspendedEmails::class);
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_date_notification_deliveries', function (Blueprint $table): void {
$table->id();
$table->string('notification_key')->unique();
$table->string('notification_type');
$table->string('tenant_code');
$table->unsignedBigInteger('event_date_id');
$table->unsignedBigInteger('destination_event_date_id')->nullable();
$table->unsignedBigInteger('purchase_id');
$table->timestamp('sent_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('event_date_notification_deliveries');
}
};

View File

@ -0,0 +1,14 @@
<h1 style="margin: 0 0 20px;">Tu evento fue reprogramado</h1>
<p>Te informamos que la fecha de tu evento cambió.</p>
<p>
<strong>Fecha anterior:</strong> {{ $previousDate }}<br>
<strong>Nueva fecha:</strong> {{ $newDate }}
</p>
<p>Tus tickets continúan siendo válidos para la nueva fecha.</p>
<p><strong>Tickets afectados</strong></p>
<ul>
@foreach ($tickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li>
@endforeach
</ul>
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p>

View File

@ -0,0 +1,23 @@
<h1 style="margin: 0 0 20px;">Actualización sobre tu evento</h1>
<p>La fecha <strong>{{ $date }}</strong> fue suspendida.</p>
@if ($disabledTickets->isNotEmpty())
<p>Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:</p>
<ul>
@foreach ($disabledTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li>
@endforeach
</ul>
<p>Para conocer las alternativas o condiciones de devolución, comunicate con la organización.</p>
@endif
@if ($activeTickets->isNotEmpty())
<p>Estos tickets conservan otras fechas disponibles:</p>
<ul>
@foreach ($activeTickets as $ticket)
<li>{{ $ticket->name }} · #{{ $ticket->id }}</li>
@endforeach
</ul>
@endif
<p style="color: #64748b; font-size: 13px;">Compra #{{ $purchase->id }}</p>

View File

@ -270,6 +270,131 @@ class NotificationMailServiceTest extends TestCase
});
}
public function test_it_sends_one_rescheduling_email_per_purchase_and_does_not_duplicate_it(): void
{
$purchase = Purchase::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'user_id' => $this->user->id,
'status' => Purchase::STATUS_PAID,
'payment_method' => 'transfer',
'total' => 25,
'email' => 'checkout@example.com',
]);
$purchaseItem = $purchase->items()->create([
'source_catalog_item_id' => $this->catalogItem()->id,
'nombre' => 'Entrada',
'item_nombre' => 'Entrada general',
'variant_attributes' => [],
'cantidad' => 2,
'precio_unitario' => 25,
'total' => 25,
]);
$firstTicket = $this->ticketFor($purchaseItem->id);
$secondTicket = $this->ticketFor($purchaseItem->id);
$purchaseTickets = [[
'purchase_id' => $purchase->id,
'ticket_ids' => [$firstTicket->id, $secondTicket->id],
]];
app(NotificationMailService::class)->sendEventDateRescheduled(
$this->tenant->codigo,
10,
20,
'09/10/2027',
'20/10/2027',
$purchaseTickets,
);
app(NotificationMailService::class)->sendEventDateRescheduled(
$this->tenant->codigo,
10,
20,
'09/10/2027',
'20/10/2027',
$purchaseTickets,
);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $firstTicket, $secondTicket): bool {
$mail->assertTo('checkout@example.com');
return $mail->subject === "Tu evento fue reprogramado - Compra #{$purchase->id}"
&& str_contains($mail->render(), '09/10/2027')
&& str_contains($mail->render(), '20/10/2027')
&& str_contains($mail->render(), '#'.$firstTicket->id)
&& str_contains($mail->render(), '#'.$secondTicket->id);
});
$this->assertDatabaseHas('event_date_notification_deliveries', [
'notification_key' => "event-date-rescheduled:10:20:{$purchase->id}",
'purchase_id' => $purchase->id,
]);
}
public function test_suspension_email_uses_the_account_email_and_separates_disabled_tickets(): void
{
$purchase = Purchase::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'user_id' => $this->user->id,
'status' => Purchase::STATUS_PAID,
'payment_method' => 'transfer',
'total' => 25,
'email' => null,
]);
$purchaseItem = $purchase->items()->create([
'source_catalog_item_id' => $this->catalogItem()->id,
'nombre' => 'Entrada',
'item_nombre' => 'Entrada general',
'variant_attributes' => [],
'cantidad' => 2,
'precio_unitario' => 25,
'total' => 25,
]);
$disabledTicket = $this->ticketFor($purchaseItem->id);
$disabledTicket->markAsDisabled();
$disabledTicket->save();
$activeTicket = $this->ticketFor($purchaseItem->id);
app(NotificationMailService::class)->sendEventDateSuspended(
$this->tenant->codigo,
10,
'09/10/2027',
[[
'purchase_id' => $purchase->id,
'ticket_ids' => [$disabledTicket->id, $activeTicket->id],
]],
);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool {
$mail->assertTo('ada@example.com');
return str_contains($mail->render(), 'quedaron inhabilitados')
&& str_contains($mail->render(), 'conservan otras fechas disponibles')
&& str_contains($mail->render(), '#'.$disabledTicket->id)
&& str_contains($mail->render(), '#'.$activeTicket->id);
});
}
private function ticketFor(int $purchaseItemId): Ticket
{
return Ticket::query()->create([
'tenant_code' => $this->tenant->codigo,
'ticket' => fake()->uuid(),
'source_purchase_item_id' => $purchaseItemId,
'user_id' => $this->user->id,
]);
}
private function catalogItem(): CatalogItem
{
return CatalogItem::query()->create([
'tenant_code' => $this->tenant->codigo,
'slug' => fake()->unique()->slug(),
'nombre' => 'Entrada',
'descripcion' => 'Entrada general',
'precio' => 25,
'has_tickets' => true,
]);
}
private function useWebsiteTypeBranding(): void
{
$websiteType = WebsiteType::query()->create([

View File

@ -2,6 +2,10 @@
namespace Tests\Feature\Notification;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Notification\Listeners\SendEventDateRescheduledEmails;
use App\Domains\Notification\Listeners\SendEventDateSuspendedEmails;
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid;
@ -27,4 +31,42 @@ class QueuedNotificationListenerTest extends TestCase
$this->assertSame(123, $purchasePaid->purchaseId);
}
public function test_rescheduled_date_email_listener_delegates_the_captured_purchase_tickets(): void
{
$mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendEventDateRescheduled')
->once()
->with('acme', 10, 20, '2027-10-09', '2027-10-20', [[
'purchase_id' => 123,
'ticket_ids' => [456, 789],
]]);
$this->app->instance(NotificationMailService::class, $mailService);
(new SendEventDateRescheduledEmails)->handle(new EventDateRescheduled(
'acme', 10, 20, '2027-10-09', '2027-10-20', [[
'purchase_id' => 123,
'ticket_ids' => [456, 789],
]]
));
}
public function test_suspended_date_email_listener_delegates_the_captured_purchase_tickets(): void
{
$mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendEventDateSuspended')
->once()
->with('acme', 10, '2027-10-09', [[
'purchase_id' => 123,
'ticket_ids' => [456],
]]);
$this->app->instance(NotificationMailService::class, $mailService);
(new SendEventDateSuspendedEmails)->handle(new EventDateSuspended(
'acme', 10, '2027-10-09', [[
'purchase_id' => 123,
'ticket_ids' => [456],
]]
));
}
}