feat(notification): add PDF attachments to ticket availability emails

This commit is contained in:
2026-08-26 08:54:15 -03:00
parent bf02d37a33
commit 8220fb5aaf
5 changed files with 52 additions and 6 deletions

View File

@@ -72,11 +72,15 @@ class MailService extends BaseIntegrationService
return []; return [];
} }
/**
* @param array<int, array{data: string, name: string, mime: string}> $attachments
*/
public function send( public function send(
string|array $recipient, string|array $recipient,
string $subject, string $subject,
string $content, string $content,
Tenant|WebsiteType|null $brand = null, Tenant|WebsiteType|null $brand = null,
array $attachments = [],
): void { ): void {
if (! $this->mailer || ! $this->tenant) { if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
@@ -105,6 +109,14 @@ class MailService extends BaseIntegrationService
->subject($subject) ->subject($subject)
->html($html); ->html($html);
foreach ($attachments as $attachment) {
$mail->attachData(
$attachment['data'],
$attachment['name'],
['mime' => $attachment['mime']],
);
}
$this->mailer->to($recipient)->send($mail); $this->mailer->to($recipient)->send($mail);
} }

View File

@@ -9,6 +9,7 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
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 App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -17,6 +18,7 @@ class NotificationMailService
{ {
public function __construct( public function __construct(
private readonly MailService $mailService, private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
) {} ) {}
public function sendWelcome(int $userId, string $tenantCode): void public function sendWelcome(int $userId, string $tenantCode): void
@@ -130,6 +132,11 @@ class NotificationMailService
$this->recipientFor($purchase), $this->recipientFor($purchase),
'Tus tickets ya están disponibles', 'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(), view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
attachments: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]],
); );
} }

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Services;
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 Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Endroid\QrCode\ErrorCorrectionLevel; use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\QrCode; use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter; use Endroid\QrCode\Writer\PngWriter;
@@ -19,12 +20,36 @@ class TicketPdfService
* @param Collection<int, Ticket> $tickets * @param Collection<int, Ticket> $tickets
*/ */
public function download(Tenant $tenant, Collection $tickets): Response public function download(Tenant $tenant, Collection $tickets): Response
{
return $this->pdf($tenant, $tickets)->download($this->filename($tickets));
}
/**
* @param Collection<int, Ticket> $tickets
*/
public function contents(Tenant $tenant, Collection $tickets): string
{
return $this->pdf($tenant, $tickets)->output();
}
/**
* @param Collection<int, Ticket> $tickets
*/
public function filename(Collection $tickets): string
{
return 'tickets_'.$tickets->pluck('id')->implode('_').'.pdf';
}
/**
* @param Collection<int, Ticket> $tickets
*/
private function pdf(Tenant $tenant, Collection $tickets): DomPdf
{ {
$tenant->loadMissing('headerLogo'); $tenant->loadMissing('headerLogo');
$primaryColor = $this->color($tenant->primary_color, '#009933'); $primaryColor = $this->color($tenant->primary_color, '#009933');
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor); $headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
$pdf = Pdf::loadView('pdf.tickets', [ return Pdf::loadView('pdf.tickets', [
'tenant' => $tenant, 'tenant' => $tenant,
'tickets' => $tickets, 'tickets' => $tickets,
'logoDataUri' => $this->logoDataUri($tenant), 'logoDataUri' => $this->logoDataUri($tenant),
@@ -35,10 +60,6 @@ class TicketPdfService
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)] fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
), ),
])->setPaper('a4'); ])->setPaper('a4');
$ticketIds = $tickets->pluck('id')->implode('_');
return $pdf->download("tickets_{$ticketIds}.pdf");
} }
private function qrCodeDataUri(string $value): string private function qrCodeDataUri(string $value): string

View File

@@ -1,5 +1,6 @@
<h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1> <h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1>
<p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p> <p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p>
<p>{{ $tickets->count() === 1 ? 'El ticket está adjunto' : 'Los tickets están adjuntos' }} a este correo en formato PDF.</p>
<ul style="padding-left: 20px;"> <ul style="padding-left: 20px;">
@foreach ($tickets as $ticket) @foreach ($tickets as $ticket)
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li> <li style="margin-bottom: 8px;">{{ $ticket->name }}</li>

View File

@@ -192,11 +192,16 @@ class NotificationMailServiceTest extends TestCase
&& str_contains($mail->render(), 'border-top: 4px solid #112233') && str_contains($mail->render(), 'border-top: 4px solid #112233')
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006'); && ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
}); });
Mail::assertSent(Mailable::class, function (Mailable $mail): bool { Mail::assertSent(Mailable::class, function (Mailable $mail) use ($ticket): bool {
$mail->assertTo('checkout@example.com'); $mail->assertTo('checkout@example.com');
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
return $mail->subject === 'Tus tickets ya están disponibles' return $mail->subject === 'Tus tickets ya están disponibles'
&& str_contains($mail->render(), 'Entrada general') && str_contains($mail->render(), 'Entrada general')
&& str_contains($mail->render(), 'El ticket está adjunto')
&& $attachment !== null
&& $attachment['options'] === ['mime' => 'application/pdf']
&& str_starts_with($attachment['data'], '%PDF-')
&& str_contains($mail->render(), 'border-top: 4px solid #112233') && str_contains($mail->render(), 'border-top: 4px solid #112233')
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006'); && ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
}); });