feat(notification): enhance email logging and refactor event handling to use scalar identifiers

This commit is contained in:
ncoronel 2026-08-26 09:38:19 -03:00
parent 28d09dedab
commit d0424c5589
13 changed files with 381 additions and 176 deletions

View File

@ -41,6 +41,8 @@ TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View File

@ -2,7 +2,6 @@
namespace App\Domains\Notification\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@ -14,7 +13,7 @@ class TicketsAvailable
* @param array<int, int> $ticketIds
*/
public function __construct(
public readonly Purchase $purchase,
public readonly int $purchaseId,
public readonly array $ticketIds,
) {}
}

View File

@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
public function handle(PasswordResetRequested $event): void
{
try {
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception,
]);
throw $exception;
}
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
}
}

View File

@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\InteractsWithQueue;
#[DeleteWhenMissingModels]
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
@ -22,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
public function handle(PurchasePaid $event): void
{
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
app(NotificationMailService::class)->sendPurchasePaid($event->purchaseId);
}
}

View File

@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\InteractsWithQueue;
#[DeleteWhenMissingModels]
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
@ -22,6 +20,6 @@ class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
public function handle(TicketsAvailable $event): void
{
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
app(NotificationMailService::class)->sendTicketsAvailable($event->purchaseId, $event->ticketIds);
}
}

View File

@ -11,8 +11,10 @@ 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 Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService
{
@ -23,18 +25,27 @@ class NotificationMailService
public function sendWelcome(int $userId, string $tenantCode): void
{
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$this->sendLogged('welcome', [
'user_id' => $userId,
'tenant_code' => $tenantCode,
], function () use ($userId, $tenantCode): array {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand,
);
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand,
);
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
}
public function sendPasswordResetCode(
@ -42,106 +53,206 @@ class NotificationMailService
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
$context = [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
];
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'attempt_status' => $attempt->status,
]);
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
return;
}
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
]));
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
return null;
}
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
});
}
public function sendPurchasePaid(int $purchaseId): void
{
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->findOrFail($purchaseId);
$context = ['purchase_id' => $purchaseId];
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
$this->sendLogged('purchase_paid', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->find($purchaseId);
if ($purchase === null) {
$this->logSkipped('purchase_paid', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
];
});
}
/** @param array<int, int> $ticketIds */
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
{
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
$context = [
'purchase_id' => $purchaseId,
'requested_ticket_count' => count($ticketIds),
'requested_ticket_ids' => $ticketIds,
];
if ($tickets->isEmpty()) {
return;
}
$this->sendLogged('tickets_available', $context, function () use ($purchaseId, $ticketIds, $context): ?array {
$purchase = Purchase::query()->with(['tenant', 'user'])->find($purchaseId);
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
attachments: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]],
);
if ($purchase === null) {
$this->logSkipped('tickets_available', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) {
$this->logSkipped('tickets_available', array_merge($context, [
'reason' => 'tickets_not_found',
'missing_model' => Ticket::class,
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
]));
return null;
}
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
attachments: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]],
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'sent_ticket_count' => $tickets->count(),
'sent_ticket_ids' => $tickets->modelKeys(),
];
});
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);
}
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send
*/
private function sendLogged(string $emailType, array $context, Closure $send): void
{
try {
$resultContext = $send();
if ($resultContext === null) {
return;
}
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
'email_type' => $emailType,
'mailer' => $this->mailService->mailerName(),
]));
} catch (Throwable $exception) {
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
'email_type' => $emailType,
'exception' => $exception,
]));
throw $exception;
}
}
/** @param array<string, mixed> $context */
private function logSkipped(string $emailType, array $context): void
{
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
'email_type' => $emailType,
]));
}
}

View File

@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@ -10,5 +9,5 @@ class PurchasePaid
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Purchase $purchase) {}
public function __construct(public readonly int $purchaseId) {}
}

View File

@ -193,7 +193,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID,
]);
PurchasePaid::dispatch($this);
PurchasePaid::dispatch($this->getKey());
});
}
}

View File

@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Listeners;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Services\TicketGeneratorService;
@ -16,10 +17,9 @@ class GenerateTicketsForPaidPurchase
public function handle(PurchasePaid $event): void
{
$purchase = $event->purchase
->newQuery()
$purchase = Purchase::query()
->with(['user', 'items'])
->findOrFail($event->purchase->getKey());
->findOrFail($event->purchaseId);
$user = $purchase->user;
$ticketIds = [];
@ -52,7 +52,7 @@ class GenerateTicketsForPaidPurchase
}
if ($ticketIds !== []) {
TicketsAvailable::dispatch($purchase, $ticketIds);
TicketsAvailable::dispatch($purchase->getKey(), $ticketIds);
}
}

View File

@ -89,6 +89,14 @@ return [
'replace_placeholders' => true,
],
'emails' => [
'driver' => 'daily',
'path' => storage_path('logs/emails/emails.log'),
'level' => env('EMAILS_LOG_LEVEL', 'info'),
'days' => env('EMAILS_LOG_DAYS', 30),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@ -5,60 +5,42 @@ namespace Tests\Feature\Notification;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Events\CallQueuedListener;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Mockery;
use Tests\TestCase;
class QueuedNotificationListenerTest extends TestCase
{
public function test_purchase_paid_email_is_discarded_when_the_purchase_no_longer_exists(): void
public function test_purchase_paid_email_delegates_with_the_purchase_id(): void
{
$job = $this->dispatchQueuedListener(
PurchasePaid::class,
SendPurchasePaidEmail::class,
new PurchasePaid($this->purchase()),
);
$mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendPurchasePaid')
->once()
->with(123);
$this->app->instance(NotificationMailService::class, $mailService);
$this->assertTrue($job->deleteWhenMissingModels);
(new SendPurchasePaidEmail)->handle(new PurchasePaid(123));
}
public function test_tickets_available_email_is_discarded_when_the_purchase_no_longer_exists(): void
public function test_tickets_available_email_delegates_with_scalar_identifiers(): void
{
$job = $this->dispatchQueuedListener(
TicketsAvailable::class,
SendTicketsAvailableEmail::class,
new TicketsAvailable($this->purchase(), [10, 11]),
);
$mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendTicketsAvailable')
->once()
->with(123, [10, 11]);
$this->app->instance(NotificationMailService::class, $mailService);
$this->assertTrue($job->deleteWhenMissingModels);
(new SendTicketsAvailableEmail)->handle(new TicketsAvailable(123, [10, 11]));
}
private function dispatchQueuedListener(string $event, string $listener, object $payload): CallQueuedListener
public function test_email_events_only_serialize_scalar_identifiers(): void
{
Queue::fake();
Event::forget($event);
Event::listen($event, $listener);
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
$ticketsAvailable = unserialize(serialize(new TicketsAvailable(123, [10, 11])));
event($payload);
$queuedListener = null;
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) use (&$queuedListener): bool {
$queuedListener = $job;
return true;
});
$this->assertInstanceOf(CallQueuedListener::class, $queuedListener);
return $queuedListener;
}
private function purchase(): Purchase
{
return (new Purchase)->forceFill(['id' => 123]);
$this->assertSame(123, $purchasePaid->purchaseId);
$this->assertSame(123, $ticketsAvailable->purchaseId);
$this->assertSame([10, 11], $ticketsAvailable->ticketIds);
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace Tests\Unit\Notification;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Services\TicketPdfService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Mockery;
use ReflectionMethod;
use RuntimeException;
use Tests\TestCase;
class NotificationMailServiceLoggingTest extends TestCase
{
private NotificationMailService $service;
private MailService $mailService;
protected function setUp(): void
{
parent::setUp();
$this->mailService = Mockery::mock(MailService::class);
$this->service = new NotificationMailService(
$this->mailService,
Mockery::mock(TicketPdfService::class),
);
}
public function test_it_logs_and_swallows_a_missing_purchase(): void
{
$this->createEmptyPurchasesTable();
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('warning')->once()->with(
'Notification email skipped.',
[
'purchase_id' => 123,
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
'email_type' => 'purchase_paid',
],
);
$this->service->sendPurchasePaid(123);
}
public function test_missing_purchase_log_includes_the_requested_ticket_ids(): void
{
$this->createEmptyPurchasesTable();
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('warning')->once()->with(
'Notification email skipped.',
[
'purchase_id' => 123,
'requested_ticket_count' => 2,
'requested_ticket_ids' => [10, 11],
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
'email_type' => 'tickets_available',
],
);
$this->service->sendTicketsAvailable(123, [10, 11]);
}
public function test_it_logs_successful_delivery_with_the_mailer(): void
{
$this->mailService->shouldReceive('mailerName')->once()->andReturn('smtp');
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('info')->once()->with(
'Notification email sent.',
[
'user_id' => 10,
'tenant_code' => 'tenant-test',
'email_type' => 'welcome',
'mailer' => 'smtp',
],
);
$this->sendLogged(
'welcome',
['user_id' => 10],
fn (): array => ['tenant_code' => 'tenant-test'],
);
}
public function test_it_logs_and_rethrows_delivery_failures(): void
{
$exception = new RuntimeException('SMTP unavailable.');
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('error')->once()->with(
'Notification email delivery failed.',
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
&& $context['email_type'] === 'purchase_paid'
&& $context['exception'] === $exception),
);
$this->expectExceptionObject($exception);
$this->sendLogged('purchase_paid', ['purchase_id' => 123], function () use ($exception): array {
throw $exception;
});
}
/** @param array<string, mixed> $context */
private function sendLogged(string $emailType, array $context, callable $send): void
{
(new ReflectionMethod($this->service, 'sendLogged'))->invoke(
$this->service,
$emailType,
$context,
$send,
);
}
private function createEmptyPurchasesTable(): void
{
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
]);
Schema::connection('sqlite')->create('compras', function (Blueprint $table): void {
$table->id();
});
}
}

View File

@ -5,13 +5,12 @@ namespace Tests\Unit\Notification;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Tests\TestCase;
class SendPasswordResetEmailTest extends TestCase
{
public function test_it_logs_and_rethrows_mail_failures(): void
public function test_it_delegates_mail_failures_to_the_notification_service(): void
{
$exception = new RuntimeException('SMTP unavailable.');
$mailService = \Mockery::mock(NotificationMailService::class);
@ -21,16 +20,6 @@ class SendPasswordResetEmailTest extends TestCase
->andThrow($exception);
$this->app->instance(NotificationMailService::class, $mailService);
Log::shouldReceive('error')
->once()
->with(
'Failed to send password reset email.',
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
&& $context['tenant_code'] === 'tenant-test'
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
&& $context['exception'] === $exception),
);
$this->expectExceptionObject($exception);
(new SendPasswordResetEmail)->handle(