From 756f4dad0a9fceb88767a81e21247cd0722acb3c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 08:53:51 -0300 Subject: [PATCH] feat(notification): email event date changes --- .../SendEventDateRescheduledEmails.php | 32 ++ .../SendEventDateSuspendedEmails.php | 30 ++ .../Models/EventDateNotificationDelivery.php | 28 ++ .../Services/NotificationMailService.php | 273 ++++++++++++++++++ app/Providers/AppServiceProvider.php | 6 + ...ent_date_notification_deliveries_table.php | 28 ++ .../event-date-rescheduled.blade.php | 14 + .../event-date-suspended.blade.php | 23 ++ .../NotificationMailServiceTest.php | 125 ++++++++ .../QueuedNotificationListenerTest.php | 42 +++ 10 files changed, 601 insertions(+) create mode 100644 app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php create mode 100644 app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php create mode 100644 app/Domains/Notification/Models/EventDateNotificationDelivery.php create mode 100644 database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php create mode 100644 resources/views/mail/notifications/event-date-rescheduled.blade.php create mode 100644 resources/views/mail/notifications/event-date-suspended.blade.php diff --git a/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php b/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php new file mode 100644 index 0000000..b119489 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendEventDateRescheduledEmails.php @@ -0,0 +1,32 @@ + */ + 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, + ); + } +} diff --git a/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php new file mode 100644 index 0000000..93af492 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendEventDateSuspendedEmails.php @@ -0,0 +1,30 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(EventDateSuspended $event): void + { + app(NotificationMailService::class)->sendEventDateSuspended( + $event->tenantCode, + $event->eventDateId, + $event->date, + $event->purchaseTickets, + ); + } +} diff --git a/app/Domains/Notification/Models/EventDateNotificationDelivery.php b/app/Domains/Notification/Models/EventDateNotificationDelivery.php new file mode 100644 index 0000000..33bd028 --- /dev/null +++ b/app/Domains/Notification/Models/EventDateNotificationDelivery.php @@ -0,0 +1,28 @@ + 'integer', + 'destination_event_date_id' => 'integer', + 'purchase_id' => 'integer', + 'sent_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index d053032..efbdabb 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -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}> $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 $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}> $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 $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); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e687726..d528318 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php new file mode 100644 index 0000000..e8070ad --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/resources/views/mail/notifications/event-date-rescheduled.blade.php b/resources/views/mail/notifications/event-date-rescheduled.blade.php new file mode 100644 index 0000000..af33d18 --- /dev/null +++ b/resources/views/mail/notifications/event-date-rescheduled.blade.php @@ -0,0 +1,14 @@ +

Tu evento fue reprogramado

+

Te informamos que la fecha de tu evento cambió.

+

+ Fecha anterior: {{ $previousDate }}
+ Nueva fecha: {{ $newDate }} +

+

Tus tickets continúan siendo válidos para la nueva fecha.

+

Tickets afectados

+
    + @foreach ($tickets as $ticket) +
  • {{ $ticket->name }} · #{{ $ticket->id }}
  • + @endforeach +
+

Compra #{{ $purchase->id }}

diff --git a/resources/views/mail/notifications/event-date-suspended.blade.php b/resources/views/mail/notifications/event-date-suspended.blade.php new file mode 100644 index 0000000..9f69adc --- /dev/null +++ b/resources/views/mail/notifications/event-date-suspended.blade.php @@ -0,0 +1,23 @@ +

Actualización sobre tu evento

+

La fecha {{ $date }} fue suspendida.

+ +@if ($disabledTickets->isNotEmpty()) +

Los siguientes tickets quedaron inhabilitados porque no tienen otra fecha disponible:

+
    + @foreach ($disabledTickets as $ticket) +
  • {{ $ticket->name }} · #{{ $ticket->id }}
  • + @endforeach +
+

Para conocer las alternativas o condiciones de devolución, comunicate con la organización.

+@endif + +@if ($activeTickets->isNotEmpty()) +

Estos tickets conservan otras fechas disponibles:

+
    + @foreach ($activeTickets as $ticket) +
  • {{ $ticket->name }} · #{{ $ticket->id }}
  • + @endforeach +
+@endif + +

Compra #{{ $purchase->id }}

diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index eef0c1c..f305d8d 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -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([ diff --git a/tests/Feature/Notification/QueuedNotificationListenerTest.php b/tests/Feature/Notification/QueuedNotificationListenerTest.php index fbf446f..f9234ce 100644 --- a/tests/Feature/Notification/QueuedNotificationListenerTest.php +++ b/tests/Feature/Notification/QueuedNotificationListenerTest.php @@ -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], + ]] + )); + } }