Compare commits
3 Commits
4f7ede1072
...
9c14153199
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c14153199 | |||
| 756f4dad0a | |||
| 205d77bc0b |
@@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info
|
|||||||
COMMANDS_LOG_DAYS=30
|
COMMANDS_LOG_DAYS=30
|
||||||
EMAILS_LOG_LEVEL=info
|
EMAILS_LOG_LEVEL=info
|
||||||
EMAILS_LOG_DAYS=30
|
EMAILS_LOG_DAYS=30
|
||||||
|
EMAIL_DELIVERY_LEASE_SECONDS=300
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
22
app/Domains/Event/Events/EventDateRescheduled.php
Normal file
22
app/Domains/Event/Events/EventDateRescheduled.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class EventDateRescheduled
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $tenantCode,
|
||||||
|
public readonly int $sourceEventDateId,
|
||||||
|
public readonly int $destinationEventDateId,
|
||||||
|
public readonly string $previousDate,
|
||||||
|
public readonly string $newDate,
|
||||||
|
public readonly array $purchaseTickets,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
20
app/Domains/Event/Events/EventDateSuspended.php
Normal file
20
app/Domains/Event/Events/EventDateSuspended.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class EventDateSuspended
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $tenantCode,
|
||||||
|
public readonly int $eventDateId,
|
||||||
|
public readonly string $date,
|
||||||
|
public readonly array $purchaseTickets,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\TicketValidityResolver;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AffectedEventDatePurchaseResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Finds active tickets belonging to paid purchases before an event-date mutation.
|
||||||
|
*
|
||||||
|
* @param Collection<int, int>|list<int> $eventDateIds
|
||||||
|
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
|
||||||
|
*/
|
||||||
|
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
|
||||||
|
{
|
||||||
|
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
|
||||||
|
|
||||||
|
if ($eventDateIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
|
$tickets = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
|
||||||
|
->where('status', Purchase::STATUS_PAID))
|
||||||
|
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
|
||||||
|
$query->whereIn('event_date_id', $eventDateIds)
|
||||||
|
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
|
||||||
|
->whereIn('event_dates.id', $eventDateIds));
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
...TicketValidityResolver::RELATIONS,
|
||||||
|
'sourcePurchaseItem.purchase',
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $tickets
|
||||||
|
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
|
||||||
|
->map(function (Collection $purchaseTickets): array {
|
||||||
|
return [
|
||||||
|
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
|
||||||
|
'ticket_ids' => $purchaseTickets->modelKeys(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Domains\Event\Services;
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Events\EventDateRescheduled;
|
||||||
|
use App\Domains\Event\Events\EventDateSuspended;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ class EventService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
||||||
|
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function forTenant(Tenant $tenant): Tenant
|
public function forTenant(Tenant $tenant): Tenant
|
||||||
@@ -111,8 +115,21 @@ class EventService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
|
||||||
|
$tenant,
|
||||||
|
$this->affectedDateIds($tenant, $source),
|
||||||
|
);
|
||||||
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
||||||
|
|
||||||
|
EventDateRescheduled::dispatch(
|
||||||
|
$tenant->codigo,
|
||||||
|
$source->getKey(),
|
||||||
|
$destination->getKey(),
|
||||||
|
$source->date->format('d/m/Y'),
|
||||||
|
$destination->date->format('d/m/Y'),
|
||||||
|
$purchaseTickets,
|
||||||
|
);
|
||||||
|
|
||||||
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
|
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,9 +149,20 @@ class EventService
|
|||||||
return $date->load('validityTime');
|
return $date->load('validityTime');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
|
||||||
|
$tenant,
|
||||||
|
$this->affectedDateIds($tenant, $date),
|
||||||
|
);
|
||||||
$date->update(['suspended_at' => now()]);
|
$date->update(['suspended_at' => now()]);
|
||||||
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
||||||
|
|
||||||
|
EventDateSuspended::dispatch(
|
||||||
|
$tenant->codigo,
|
||||||
|
$date->getKey(),
|
||||||
|
$date->date->format('d/m/Y'),
|
||||||
|
$purchaseTickets,
|
||||||
|
);
|
||||||
|
|
||||||
return $date->fresh('validityTime');
|
return $date->fresh('validityTime');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,9 +200,10 @@ class EventService
|
|||||||
return $current->is($expected);
|
return $current->is($expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
|
/** @return Collection<int, int> */
|
||||||
|
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
|
||||||
{
|
{
|
||||||
$affectedDateIds = collect([$suspendedDate->getKey()]);
|
$affectedDateIds = collect([$eventDate->getKey()]);
|
||||||
$frontier = $affectedDateIds;
|
$frontier = $affectedDateIds;
|
||||||
|
|
||||||
while ($frontier->isNotEmpty()) {
|
while ($frontier->isNotEmpty()) {
|
||||||
@@ -187,6 +216,13 @@ class EventService
|
|||||||
$frontier = $predecessors;
|
$frontier = $predecessors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $affectedDateIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
|
||||||
|
{
|
||||||
|
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
|
||||||
|
|
||||||
$variants = Variant::withTrashed()
|
$variants = Variant::withTrashed()
|
||||||
->where(function ($query) use ($affectedDateIds): void {
|
->where(function ($query) use ($affectedDateIds): void {
|
||||||
$query->whereIn('event_date_id', $affectedDateIds)
|
$query->whereIn('event_date_id', $affectedDateIds)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Domains/Notification/Models/EmailDelivery.php
Normal file
44
app/Domains/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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use App\Domains\Ticket\Services\TicketPdfService;
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||||
|
use App\Domains\Ticket\Services\TicketValidityResolver;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -21,32 +22,42 @@ class NotificationMailService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailService $mailService,
|
private readonly MailService $mailService,
|
||||||
private readonly TicketPdfService $ticketPdfService,
|
private readonly TicketPdfService $ticketPdfService,
|
||||||
|
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function sendWelcome(int $userId, string $tenantCode): void
|
public function sendWelcome(int $userId, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$this->sendLogged('welcome', [
|
$context = [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'tenant_code' => $tenantCode,
|
'tenant_code' => $tenantCode,
|
||||||
], function () use ($userId, $tenantCode): array {
|
];
|
||||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
|
||||||
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
|
||||||
|
|
||||||
$this->mailService
|
$this->sendIdempotently(
|
||||||
->forTenant($tenantCode)
|
"welcome:{$tenantCode}:{$userId}",
|
||||||
->send(
|
'welcome',
|
||||||
$user->email,
|
$tenantCode,
|
||||||
"Bienvenido a {$brand->nombre}",
|
$context,
|
||||||
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
$user->email,
|
||||||
$brand,
|
function () use ($user, $tenant, $tenantCode): array {
|
||||||
);
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||||
|
|
||||||
return [
|
$this->mailService
|
||||||
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
->forTenant($tenantCode)
|
||||||
];
|
->send(
|
||||||
});
|
$user->email,
|
||||||
|
"Bienvenido a {$brand->nombre}",
|
||||||
|
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||||
|
$brand,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPasswordResetCode(
|
public function sendPasswordResetCode(
|
||||||
@@ -60,128 +71,356 @@ class NotificationMailService
|
|||||||
'channel' => $channel,
|
'channel' => $channel,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
$tenant = Tenant::query()
|
||||||
$tenant = Tenant::query()
|
->with('websiteType')
|
||||||
->with('websiteType')
|
->where('codigo', $tenantCode)
|
||||||
->where('codigo', $tenantCode)
|
->firstOrFail();
|
||||||
->firstOrFail();
|
$attempt = ResetPasswordAttempt::query()
|
||||||
$attempt = ResetPasswordAttempt::query()
|
->with('user')
|
||||||
->with('user')
|
->findOrFail($attemptId);
|
||||||
->findOrFail($attemptId);
|
|
||||||
|
|
||||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||||
$this->logSkipped('password_reset', array_merge($context, [
|
$this->logSkipped('password_reset', array_merge($context, [
|
||||||
'reason' => 'attempt_not_pending',
|
'reason' => 'attempt_not_pending',
|
||||||
'attempt_status' => $attempt->status,
|
'attempt_status' => $attempt->status,
|
||||||
'user_id' => $attempt->user_id,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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;
|
|
||||||
|
|
||||||
[$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,
|
'user_id' => $attempt->user_id,
|
||||||
'recovery_domain_available' => $recoveryDomain !== null,
|
]));
|
||||||
];
|
|
||||||
});
|
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->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;
|
||||||
|
|
||||||
|
[$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
|
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$context = ['purchase_id' => $purchaseId];
|
$context = ['purchase_id' => $purchaseId];
|
||||||
|
|
||||||
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
$purchase = Purchase::query()
|
||||||
$purchase = Purchase::query()
|
->with(['tenant', 'user', 'items'])
|
||||||
->with(['tenant', 'user', 'items'])
|
->find($purchaseId);
|
||||||
->find($purchaseId);
|
|
||||||
|
|
||||||
if ($purchase === null) {
|
if ($purchase === null) {
|
||||||
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||||
'reason' => 'purchase_not_found',
|
'reason' => 'purchase_not_found',
|
||||||
'missing_model' => Purchase::class,
|
'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',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = $purchase->tickets()
|
$tickets = $purchase->tickets()
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $tenantCode)
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->whereKey($ticketIds)
|
||||||
->get();
|
->with([
|
||||||
$attachments = $tickets->isEmpty()
|
...TicketPresentationResolver::RELATIONS,
|
||||||
? []
|
...TicketValidityResolver::RELATIONS,
|
||||||
: [[
|
])
|
||||||
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
->get()
|
||||||
'name' => $this->ticketPdfService->filename($tickets),
|
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
|
||||||
'mime' => 'application/pdf',
|
->values();
|
||||||
]];
|
|
||||||
|
|
||||||
$this->mailService
|
if ($tickets->isEmpty()) {
|
||||||
->forTenant($purchase->tenant_codigo)
|
$this->logSkipped('event_date_rescheduled', array_merge($context, [
|
||||||
->send(
|
'reason' => 'no_longer_active_tickets',
|
||||||
$this->recipientFor($purchase),
|
]));
|
||||||
"Compra confirmada - Compra #{$purchase->getKey()}",
|
|
||||||
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
|
||||||
attachments: $attachments,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
continue;
|
||||||
'tenant_code' => $purchase->tenant_codigo,
|
}
|
||||||
'user_id' => $purchase->user_id,
|
|
||||||
'purchase_status' => $purchase->status,
|
$recipient = $this->recipientFor($purchase);
|
||||||
'purchase_item_count' => $purchase->items->count(),
|
if ($recipient === '') {
|
||||||
'ticket_count' => $tickets->count(),
|
$this->logSkipped('event_date_rescheduled', array_merge($context, [
|
||||||
'ticket_ids' => $tickets->modelKeys(),
|
'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->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()];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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->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(),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase
|
||||||
|
{
|
||||||
|
return Purchase::query()
|
||||||
|
->where('tenant_codigo', $tenantCode)
|
||||||
|
->with(['tenant.websiteType', 'user'])
|
||||||
|
->find($purchaseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recipientFor(Purchase $purchase): string
|
private function recipientFor(Purchase $purchase): string
|
||||||
@@ -189,6 +428,34 @@ class NotificationMailService
|
|||||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
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 array<string, mixed> $context
|
||||||
* @param Closure(): (array<string, mixed>|null) $send
|
* @param Closure(): (array<string, mixed>|null) $send
|
||||||
|
|||||||
@@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
|||||||
|
|
||||||
## Componentes
|
## Componentes
|
||||||
|
|
||||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
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
|
## API y dependencias
|
||||||
|
|
||||||
@@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
|
|||||||
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
|
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
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\Models\Integration;
|
||||||
use App\Domains\Integration\Policies\IntegrationPolicy;
|
use App\Domains\Integration\Policies\IntegrationPolicy;
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
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\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
@@ -40,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
);
|
);
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::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(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ return [
|
|||||||
|
|
||||||
'default' => env('MAIL_MAILER', 'log'),
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
'delivery_lease_seconds' => (int) env('EMAIL_DELIVERY_LEASE_SECONDS', 300),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Mailer Configurations
|
| Mailer Configurations
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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('email_deliveries', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('idempotency_key')->unique();
|
||||||
|
$table->string('email_type')->index();
|
||||||
|
$table->string('tenant_code')->nullable()->index();
|
||||||
|
$table->string('status')->index();
|
||||||
|
$table->unsignedInteger('attempts')->default(0);
|
||||||
|
$table->json('context')->nullable();
|
||||||
|
$table->string('recipient_fingerprint', 64)->nullable();
|
||||||
|
$table->uuid('claim_token')->nullable()->index();
|
||||||
|
$table->timestamp('claimed_at')->nullable();
|
||||||
|
$table->timestamp('lease_expires_at')->nullable()->index();
|
||||||
|
$table->timestamp('sent_at')->nullable();
|
||||||
|
$table->timestamp('failed_at')->nullable();
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('email_deliveries');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -9,6 +9,8 @@ use App\Domains\Authorization\Enums\RoleCode;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Events\EventDateRescheduled;
|
||||||
|
use App\Domains\Event\Events\EventDateSuspended;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\WebsiteType;
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use App\Domains\Ticket\Enums\ValidityTimeType;
|
use App\Domains\Ticket\Enums\ValidityTimeType;
|
||||||
@@ -16,6 +18,7 @@ use App\Domains\Ticket\Models\Ticket;
|
|||||||
use Database\Seeders\AuthorizationSeeder;
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
use Database\Seeders\SocialMediaSeeder;
|
use Database\Seeders\SocialMediaSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -230,6 +233,7 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void
|
public function test_rescheduling_reuses_an_existing_date_and_tickets_resolve_its_validity(): void
|
||||||
{
|
{
|
||||||
|
Event::fake([EventDateRescheduled::class]);
|
||||||
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
|
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
|
||||||
$admin = $this->createAdminAppUser($tenant);
|
$admin = $this->createAdminAppUser($tenant);
|
||||||
$original = $tenant->eventDates()->create([
|
$original = $tenant->eventDates()->create([
|
||||||
@@ -253,6 +257,15 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.status', 'rescheduled')
|
->assertJsonPath('data.status', 'rescheduled')
|
||||||
->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id);
|
->assertJsonPath('data.rescheduled_to_event_date_id', $destination->id);
|
||||||
|
|
||||||
|
Event::assertDispatched(EventDateRescheduled::class, function (EventDateRescheduled $event) use ($tenant, $original, $destination): bool {
|
||||||
|
return $event->tenantCode === $tenant->codigo
|
||||||
|
&& $event->sourceEventDateId === $original->id
|
||||||
|
&& $event->destinationEventDateId === $destination->id
|
||||||
|
&& $event->previousDate === '09/10/2027'
|
||||||
|
&& $event->newDate === '20/10/2027'
|
||||||
|
&& $event->purchaseTickets === [];
|
||||||
|
});
|
||||||
|
|
||||||
$this->assertDatabaseCount('event_dates', 2);
|
$this->assertDatabaseCount('event_dates', 2);
|
||||||
$this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text);
|
$this->assertSame('20 de Octubre 2027', $tenant->fresh()->event_date_text);
|
||||||
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
|
$this->getJson('/api/tenants/bootstrap?dominio=acme.test&path=%2F')
|
||||||
@@ -288,6 +301,7 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_suspending_disables_only_tickets_without_another_usable_date(): void
|
public function test_suspending_disables_only_tickets_without_another_usable_date(): void
|
||||||
{
|
{
|
||||||
|
Event::fake([EventDateSuspended::class]);
|
||||||
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
|
$tenant = $this->createActiveEvent($this->createTenant('acme'), 'Festival Acme');
|
||||||
$admin = $this->createAdminAppUser($tenant);
|
$admin = $this->createAdminAppUser($tenant);
|
||||||
$suspendedDate = $tenant->eventDates()->create([
|
$suspendedDate = $tenant->eventDates()->create([
|
||||||
@@ -312,6 +326,13 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.status', 'suspended')
|
->assertJsonPath('data.status', 'suspended')
|
||||||
->assertJsonPath('data.suspended_at', fn ($value) => is_string($value));
|
->assertJsonPath('data.suspended_at', fn ($value) => is_string($value));
|
||||||
|
|
||||||
|
Event::assertDispatched(EventDateSuspended::class, function (EventDateSuspended $event) use ($tenant, $suspendedDate): bool {
|
||||||
|
return $event->tenantCode === $tenant->codigo
|
||||||
|
&& $event->eventDateId === $suspendedDate->id
|
||||||
|
&& $event->date === '09/10/2027'
|
||||||
|
&& $event->purchaseTickets === [];
|
||||||
|
});
|
||||||
|
|
||||||
$this->assertNotNull($singleDateTicket->fresh()->disabled_at);
|
$this->assertNotNull($singleDateTicket->fresh()->disabled_at);
|
||||||
$this->assertNull($multipleDateTicket->fresh()->disabled_at);
|
$this->assertNull($multipleDateTicket->fresh()->disabled_at);
|
||||||
$this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text);
|
$this->assertSame('10 de Octubre 2027', $tenant->fresh()->event_date_text);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Notification;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Models\EmailDelivery;
|
||||||
|
use App\Domains\Notification\Services\IdempotentEmailDeliveryService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class IdempotentEmailDeliveryServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_sends_once_for_the_same_business_key(): void
|
||||||
|
{
|
||||||
|
$calls = 0;
|
||||||
|
$service = app(IdempotentEmailDeliveryService::class);
|
||||||
|
|
||||||
|
$first = $service->sendOnce(
|
||||||
|
'welcome:tenant:10',
|
||||||
|
'welcome',
|
||||||
|
'tenant',
|
||||||
|
['user_id' => 10],
|
||||||
|
'ada@example.com',
|
||||||
|
function () use (&$calls): void {
|
||||||
|
$calls++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
$second = $service->sendOnce(
|
||||||
|
'welcome:tenant:10',
|
||||||
|
'welcome',
|
||||||
|
'tenant',
|
||||||
|
['user_id' => 10],
|
||||||
|
'ada@example.com',
|
||||||
|
function () use (&$calls): void {
|
||||||
|
$calls++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($first);
|
||||||
|
$this->assertFalse($second);
|
||||||
|
$this->assertSame(1, $calls);
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => 'welcome:tenant:10',
|
||||||
|
'status' => EmailDelivery::STATUS_SENT,
|
||||||
|
'attempts' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_records_a_failure_and_allows_a_retry(): void
|
||||||
|
{
|
||||||
|
$service = app(IdempotentEmailDeliveryService::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->sendOnce(
|
||||||
|
'purchase-confirmed:20',
|
||||||
|
'purchase_confirmed',
|
||||||
|
'tenant',
|
||||||
|
['purchase_id' => 20],
|
||||||
|
'buyer@example.com',
|
||||||
|
fn () => throw new RuntimeException('Sensitive SMTP detail'),
|
||||||
|
);
|
||||||
|
$this->fail('The delivery exception was not rethrown.');
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => 'purchase-confirmed:20',
|
||||||
|
'status' => EmailDelivery::STATUS_FAILED,
|
||||||
|
'attempts' => 1,
|
||||||
|
'last_error' => RuntimeException::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent = $service->sendOnce(
|
||||||
|
'purchase-confirmed:20',
|
||||||
|
'purchase_confirmed',
|
||||||
|
'tenant',
|
||||||
|
['purchase_id' => 20],
|
||||||
|
'buyer@example.com',
|
||||||
|
static function (): void {},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($sent);
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => 'purchase-confirmed:20',
|
||||||
|
'status' => EmailDelivery::STATUS_SENT,
|
||||||
|
'attempts' => 2,
|
||||||
|
'last_error' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_recovers_an_expired_claim_but_not_an_active_one(): void
|
||||||
|
{
|
||||||
|
config(['mail.delivery_lease_seconds' => 300]);
|
||||||
|
$service = app(IdempotentEmailDeliveryService::class);
|
||||||
|
$delivery = EmailDelivery::query()->create([
|
||||||
|
'idempotency_key' => 'password-reset:30',
|
||||||
|
'email_type' => 'password_reset',
|
||||||
|
'tenant_code' => 'tenant',
|
||||||
|
'status' => EmailDelivery::STATUS_PROCESSING,
|
||||||
|
'attempts' => 1,
|
||||||
|
'context' => ['attempt_id' => 30],
|
||||||
|
'recipient_fingerprint' => str_repeat('a', 64),
|
||||||
|
'claim_token' => fake()->uuid(),
|
||||||
|
'claimed_at' => now(),
|
||||||
|
'lease_expires_at' => now()->addMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activeClaim = $service->sendOnce(
|
||||||
|
$delivery->idempotency_key,
|
||||||
|
$delivery->email_type,
|
||||||
|
$delivery->tenant_code,
|
||||||
|
$delivery->context,
|
||||||
|
'ada@example.com',
|
||||||
|
static function (): void {},
|
||||||
|
);
|
||||||
|
$this->assertFalse($activeClaim);
|
||||||
|
|
||||||
|
$delivery->update(['lease_expires_at' => now()->subSecond()]);
|
||||||
|
$expiredClaim = $service->sendOnce(
|
||||||
|
$delivery->idempotency_key,
|
||||||
|
$delivery->email_type,
|
||||||
|
$delivery->tenant_code,
|
||||||
|
$delivery->context,
|
||||||
|
'ada@example.com',
|
||||||
|
static function (): void {},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($expiredClaim);
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => 'password-reset:30',
|
||||||
|
'status' => EmailDelivery::STATUS_SENT,
|
||||||
|
'attempts' => 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\WebsiteType;
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
@@ -31,6 +32,7 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->app->register(DomPdfServiceProvider::class);
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
Integration::query()->create([
|
Integration::query()->create([
|
||||||
'integration_code' => 'email',
|
'integration_code' => 'email',
|
||||||
@@ -75,6 +77,9 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
$this->useWebsiteTypeBranding();
|
$this->useWebsiteTypeBranding();
|
||||||
|
|
||||||
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
|
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
|
||||||
|
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, 1);
|
||||||
|
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||||
$mail->assertTo('ada@example.com');
|
$mail->assertTo('ada@example.com');
|
||||||
@@ -98,6 +103,12 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
$attempt->id,
|
$attempt->id,
|
||||||
$this->tenant->codigo,
|
$this->tenant->codigo,
|
||||||
);
|
);
|
||||||
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
|
$attempt->id,
|
||||||
|
$this->tenant->codigo,
|
||||||
|
);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, 1);
|
||||||
|
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||||
$mail->assertTo('ada@example.com');
|
$mail->assertTo('ada@example.com');
|
||||||
@@ -113,6 +124,27 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_idempotency_is_scoped_to_each_attempt(): void
|
||||||
|
{
|
||||||
|
$firstAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '0123']);
|
||||||
|
$secondAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '4567']);
|
||||||
|
$service = app(NotificationMailService::class);
|
||||||
|
|
||||||
|
$service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo);
|
||||||
|
$service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo);
|
||||||
|
$service->sendPasswordResetCode($secondAttempt->id, $this->tenant->codigo);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, 2);
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => "password-reset:{$firstAttempt->id}",
|
||||||
|
'status' => 'sent',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('email_deliveries', [
|
||||||
|
'idempotency_key' => "password-reset:{$secondAttempt->id}",
|
||||||
|
'status' => 'sent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void
|
public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void
|
||||||
{
|
{
|
||||||
$websiteType = WebsiteType::query()->create([
|
$websiteType = WebsiteType::query()->create([
|
||||||
@@ -261,6 +293,9 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
||||||
|
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, 1);
|
||||||
|
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
||||||
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||||
@@ -270,6 +305,143 @@ 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('email_deliveries', [
|
||||||
|
'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}",
|
||||||
|
'email_type' => 'event_date_rescheduled',
|
||||||
|
'status' => 'sent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
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, 1);
|
||||||
|
|
||||||
|
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
|
private function useWebsiteTypeBranding(): void
|
||||||
{
|
{
|
||||||
$websiteType = WebsiteType::query()->create([
|
$websiteType = WebsiteType::query()->create([
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Notification;
|
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\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
@@ -27,4 +31,42 @@ class QueuedNotificationListenerTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(123, $purchasePaid->purchaseId);
|
$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],
|
||||||
|
]]
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Unit\Notification;
|
namespace Tests\Unit\Notification;
|
||||||
|
|
||||||
use App\Domains\Integration\Services\MailService;
|
use App\Domains\Integration\Services\MailService;
|
||||||
|
use App\Domains\Notification\Services\IdempotentEmailDeliveryService;
|
||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Ticket\Services\TicketPdfService;
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
@@ -28,6 +29,7 @@ class NotificationMailServiceLoggingTest extends TestCase
|
|||||||
$this->service = new NotificationMailService(
|
$this->service = new NotificationMailService(
|
||||||
$this->mailService,
|
$this->mailService,
|
||||||
Mockery::mock(TicketPdfService::class),
|
Mockery::mock(TicketPdfService::class),
|
||||||
|
Mockery::mock(IdempotentEmailDeliveryService::class),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user