refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
23
app/Shared/Notification/Events/PasswordResetRequested.php
Normal file
23
app/Shared/Notification/Events/PasswordResetRequested.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PasswordResetRequested
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public const CHANNEL_STOREFRONT = 'storefront';
|
||||
|
||||
public const CHANNEL_ADMINAPP = 'adminapp';
|
||||
|
||||
public const CHANNEL_SCANNER = 'scanner';
|
||||
|
||||
public function __construct(
|
||||
public readonly int $attemptId,
|
||||
public readonly string $tenantCode,
|
||||
public readonly string $channel = self::CHANNEL_STOREFRONT,
|
||||
) {}
|
||||
}
|
||||
17
app/Shared/Notification/Events/UserRegistered.php
Normal file
17
app/Shared/Notification/Events/UserRegistered.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Events;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserRegistered
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly string $tenantCode,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
app/Shared/Notification/Listeners/SendPasswordResetEmail.php
Normal file
29
app/Shared/Notification/Listeners/SendPasswordResetEmail.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Listeners;
|
||||
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendPasswordResetEmail 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(PasswordResetRequested $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
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\InteractsWithQueue;
|
||||
|
||||
class SendPurchaseConfirmedEmail 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(PurchasePaid $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||
}
|
||||
}
|
||||
25
app/Shared/Notification/Listeners/SendWelcomeEmail.php
Normal file
25
app/Shared/Notification/Listeners/SendWelcomeEmail.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Listeners;
|
||||
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendWelcomeEmail 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(UserRegistered $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendWelcome($event->user->getKey(), $event->tenantCode);
|
||||
}
|
||||
}
|
||||
44
app/Shared/Notification/Models/EmailDelivery.php
Normal file
44
app/Shared/Notification/Models/EmailDelivery.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable([
|
||||
'idempotency_key',
|
||||
'email_type',
|
||||
'tenant_code',
|
||||
'status',
|
||||
'attempts',
|
||||
'context',
|
||||
'recipient_fingerprint',
|
||||
'claim_token',
|
||||
'claimed_at',
|
||||
'lease_expires_at',
|
||||
'sent_at',
|
||||
'failed_at',
|
||||
'last_error',
|
||||
])]
|
||||
class EmailDelivery extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attempts' => 'integer',
|
||||
'context' => 'array',
|
||||
'claimed_at' => 'datetime',
|
||||
'lease_expires_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Services;
|
||||
|
||||
use App\Domains\Notification\Models\EmailDelivery;
|
||||
use Closure;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class IdempotentEmailDeliveryService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param Closure(): void $send
|
||||
*/
|
||||
public function sendOnce(
|
||||
string $key,
|
||||
string $type,
|
||||
?string $tenantCode,
|
||||
array $context,
|
||||
string $recipient,
|
||||
Closure $send,
|
||||
): bool {
|
||||
$now = now();
|
||||
|
||||
EmailDelivery::query()->insertOrIgnore([
|
||||
'idempotency_key' => $key,
|
||||
'email_type' => $type,
|
||||
'tenant_code' => $tenantCode,
|
||||
'status' => EmailDelivery::STATUS_PENDING,
|
||||
'attempts' => 0,
|
||||
'context' => json_encode($context, JSON_THROW_ON_ERROR),
|
||||
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$claimToken = (string) Str::uuid();
|
||||
$leaseExpiresAt = $now->copy()->addSeconds(
|
||||
max(1, (int) config('mail.delivery_lease_seconds', 300)),
|
||||
);
|
||||
|
||||
$claimed = EmailDelivery::query()
|
||||
->where('idempotency_key', $key)
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->whereIn('status', [
|
||||
EmailDelivery::STATUS_PENDING,
|
||||
EmailDelivery::STATUS_FAILED,
|
||||
])->orWhere(function ($query) use ($now): void {
|
||||
$query->where('status', EmailDelivery::STATUS_PROCESSING)
|
||||
->where('lease_expires_at', '<=', $now);
|
||||
});
|
||||
})
|
||||
->update([
|
||||
'status' => EmailDelivery::STATUS_PROCESSING,
|
||||
'attempts' => new Expression('attempts + 1'),
|
||||
'context' => json_encode($context, JSON_THROW_ON_ERROR),
|
||||
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
|
||||
'claim_token' => $claimToken,
|
||||
'claimed_at' => $now,
|
||||
'lease_expires_at' => $leaseExpiresAt,
|
||||
'failed_at' => null,
|
||||
'last_error' => null,
|
||||
'updated_at' => $now,
|
||||
]) === 1;
|
||||
|
||||
if (! $claimed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$send();
|
||||
|
||||
EmailDelivery::query()
|
||||
->where('idempotency_key', $key)
|
||||
->where('claim_token', $claimToken)
|
||||
->update([
|
||||
'status' => EmailDelivery::STATUS_SENT,
|
||||
'claim_token' => null,
|
||||
'lease_expires_at' => null,
|
||||
'sent_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
EmailDelivery::query()
|
||||
->where('idempotency_key', $key)
|
||||
->where('claim_token', $claimToken)
|
||||
->update([
|
||||
'status' => EmailDelivery::STATUS_FAILED,
|
||||
'claim_token' => null,
|
||||
'lease_expires_at' => null,
|
||||
'failed_at' => now(),
|
||||
'last_error' => Str::limit($exception::class, 2000, ''),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function recipientFingerprint(string $recipient): string
|
||||
{
|
||||
return hash_hmac(
|
||||
'sha256',
|
||||
mb_strtolower(trim($recipient)),
|
||||
(string) config('app.key'),
|
||||
);
|
||||
}
|
||||
}
|
||||
495
app/Shared/Notification/Services/NotificationMailService.php
Normal file
495
app/Shared/Notification/Services/NotificationMailService.php
Normal file
@@ -0,0 +1,495 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Services;
|
||||
|
||||
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\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;
|
||||
use Throwable;
|
||||
|
||||
class NotificationMailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailService $mailService,
|
||||
private readonly TicketPdfService $ticketPdfService,
|
||||
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
|
||||
) {}
|
||||
|
||||
public function sendWelcome(int $userId, string $tenantCode): void
|
||||
{
|
||||
$context = [
|
||||
'user_id' => $userId,
|
||||
'tenant_code' => $tenantCode,
|
||||
];
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
$this->sendIdempotently(
|
||||
"welcome:{$tenantCode}:{$userId}",
|
||||
'welcome',
|
||||
$tenantCode,
|
||||
$context,
|
||||
$user->email,
|
||||
function () use ($user, $tenant, $tenantCode): array {
|
||||
$brand = $tenant;
|
||||
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$brand->nombre}",
|
||||
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'brand_type' => 'tenant',
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function sendPasswordResetCode(
|
||||
int $attemptId,
|
||||
string $tenantCode,
|
||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||
): void {
|
||||
$context = [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'channel' => $channel,
|
||||
];
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->with('adminWebsiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
|
||||
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,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendIdempotently(
|
||||
"password-reset:{$attemptId}",
|
||||
'password_reset',
|
||||
$tenantCode,
|
||||
$context,
|
||||
$attempt->user->email,
|
||||
function () use ($attempt, $tenant, $tenantCode, $channel): array {
|
||||
$recoveryDomain = match ($channel) {
|
||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->adminWebsiteType?->dominio,
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->adminWebsiteType?->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 = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
? $tenant
|
||||
: ($tenant->adminWebsiteType ?? $tenant);
|
||||
|
||||
[$subject, $template] = match ($attempt->reason) {
|
||||
ResetPasswordAttempt::REASON_STAFF_CREATED => [
|
||||
'Tu cuenta de escáner está lista', 'scanner-created',
|
||||
],
|
||||
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
|
||||
'Tu cuenta de administrador está lista', 'administrator-created',
|
||||
],
|
||||
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
|
||||
'Desbloqueá tu cuenta', 'account-locked',
|
||||
],
|
||||
default => ['Código para recuperar tu contraseña', 'password-reset'],
|
||||
};
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$attempt->user->email,
|
||||
"{$subject} - {$brand->nombre}",
|
||||
view("mail.notifications.{$template}", [
|
||||
'attempt' => $attempt,
|
||||
'recoveryUrl' => $recoveryUrl,
|
||||
'brand' => $brand,
|
||||
])->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'user_id' => $attempt->user_id,
|
||||
'recovery_domain_available' => $recoveryDomain !== null,
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||
{
|
||||
$context = ['purchase_id' => $purchaseId];
|
||||
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->find($purchaseId);
|
||||
|
||||
if ($purchase === null) {
|
||||
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recipient = $this->recipientFor($purchase);
|
||||
if ($recipient === '') {
|
||||
$this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient']));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendIdempotently(
|
||||
"purchase-confirmed:{$purchaseId}",
|
||||
'purchase_confirmed',
|
||||
$purchase->tenant_codigo,
|
||||
$context,
|
||||
$recipient,
|
||||
function () use ($purchase, $recipient): array {
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = $purchase->tickets()
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
->with(TicketPresentationResolver::RELATIONS)
|
||||
->get();
|
||||
$attachments = $tickets->isEmpty()
|
||||
? []
|
||||
: [[
|
||||
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||
'name' => $this->ticketPdfService->filename($tickets),
|
||||
'mime' => 'application/pdf',
|
||||
]];
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$recipient,
|
||||
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
||||
attachments: $attachments,
|
||||
);
|
||||
|
||||
return [
|
||||
'tenant_code' => $purchase->tenant_codigo,
|
||||
'user_id' => $purchase->user_id,
|
||||
'purchase_status' => $purchase->status,
|
||||
'purchase_item_count' => $purchase->items->count(),
|
||||
'ticket_count' => $tickets->count(),
|
||||
'ticket_ids' => $tickets->modelKeys(),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}";
|
||||
$this->sendIdempotently(
|
||||
$deliveryKey,
|
||||
'event_date_rescheduled',
|
||||
$tenantCode,
|
||||
$context,
|
||||
$recipient,
|
||||
function () use (
|
||||
$tenantCode,
|
||||
$purchase,
|
||||
$recipient,
|
||||
$previousDate,
|
||||
$newDate,
|
||||
$tickets,
|
||||
): array {
|
||||
$brand = $purchase->tenant;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$recipient,
|
||||
"Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
|
||||
view('mail.notifications.event-date-rescheduled', compact(
|
||||
'purchase', 'previousDate', 'newDate', 'tickets'
|
||||
))->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return ['ticket_count' => $tickets->count()];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}";
|
||||
$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();
|
||||
|
||||
$this->sendIdempotently(
|
||||
$deliveryKey,
|
||||
'event_date_suspended',
|
||||
$tenantCode,
|
||||
$context,
|
||||
$recipient,
|
||||
function () use (
|
||||
$tenantCode,
|
||||
$purchase,
|
||||
$recipient,
|
||||
$date,
|
||||
$disabledTickets,
|
||||
$activeTickets,
|
||||
): array {
|
||||
$brand = $purchase->tenant;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$recipient,
|
||||
"Una fecha de tu evento fue suspendida - N° de Orden #{$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(),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase
|
||||
{
|
||||
return Purchase::query()
|
||||
->where('tenant_codigo', $tenantCode)
|
||||
->with(['tenant', 'user'])
|
||||
->find($purchaseId);
|
||||
}
|
||||
|
||||
private function recipientFor(Purchase $purchase): string
|
||||
{
|
||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param Closure(): array<string, mixed> $send
|
||||
*/
|
||||
private function sendIdempotently(
|
||||
string $key,
|
||||
string $emailType,
|
||||
?string $tenantCode,
|
||||
array $context,
|
||||
string $recipient,
|
||||
Closure $send,
|
||||
): void {
|
||||
$sent = $this->emailDeliveryService->sendOnce(
|
||||
$key,
|
||||
$emailType,
|
||||
$tenantCode,
|
||||
$context,
|
||||
$recipient,
|
||||
function () use ($emailType, $context, $send): void {
|
||||
$this->sendLogged($emailType, $context, $send);
|
||||
},
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
$this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
48
app/Shared/Notification/documentacion/README.md
Normal file
48
app/Shared/Notification/documentacion/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Dominio Notification
|
||||
|
||||
## Propósito
|
||||
|
||||
Orquesta notificaciones de negocio por correo a partir de eventos de otros dominios.
|
||||
|
||||
## Eventos atendidos
|
||||
|
||||
- `UserRegistered`: dispara el correo de bienvenida.
|
||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||
|
||||
## Componentes
|
||||
|
||||
Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
|
||||
`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla
|
||||
`email_deliveries`. Cada correo utiliza una clave de negocio única:
|
||||
|
||||
- bienvenida: `welcome:{tenant_code}:{user_id}`;
|
||||
- recuperación: `password-reset:{attempt_id}`;
|
||||
- compra confirmada: `purchase-confirmed:{purchase_id}`;
|
||||
- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`;
|
||||
- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`.
|
||||
|
||||
Los correos de prueba y de validación de una integración SMTP no usan esta capa,
|
||||
porque su reenvío explícito es parte de su comportamiento esperado.
|
||||
|
||||
## API y dependencias
|
||||
|
||||
No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, y delega la entrega al dominio `Integration`.
|
||||
|
||||
## Consideraciones
|
||||
|
||||
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
||||
- La recuperación no se envía si el intento dejó de estar pendiente.
|
||||
- La bienvenida y la recuperación del storefront usan la identidad visual del tenant. La recuperación del admin y scanner usa el `AdminWebsiteType`, con fallback al tenant si no tiene uno configurado.
|
||||
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
|
||||
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||
- Una entrega queda en estado `processing` mientras un worker posee su claim. Si
|
||||
el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y
|
||||
otro intento puede recuperarlo.
|
||||
- Los fallos quedan registrados como `failed` y pueden ser retomados por los
|
||||
reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas
|
||||
posteriores con la misma clave no vuelven a enviar el correo.
|
||||
- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una
|
||||
interrupción ocurrida después de entregar el correo y antes de registrar
|
||||
`sent` puede producir un duplicado excepcional al recuperar el claim.
|
||||
Reference in New Issue
Block a user