Trip Access Authentication
Passwordless authentication for passengers to access their trip details via personalized magic links. Allows travel agents to share trip information directly with passengers, independent of the client authentication system.
Architecture
Section titled “Architecture”+-------------------+ +-------------------+ +-------------------+| Filament Admin | | Laravel Backend | | Astro Frontend || (Backoffice) | | | | (Trip Page) |+-------------------+ +-------------------+ +-------------------+ | | | | "Share Trip with Pax" | | |----------------------------->| | | | | | | Magic Link Email | | |----------------------------->| | | | | | Verify Token | | |<-----------------------------| | | | | | Session + Redirect | | |----------------------------->| | | |Difference from Client Authentication
Section titled “Difference from Client Authentication”| Aspect | Client Auth | Trip Access |
|---|---|---|
| Target user | Clients (booking owners) | Passengers (travelers) |
| Scope | All client bookings | Single booking only |
| Token storage | clients + Sanctum | Sanctum personal_access_tokens (polymorphic) |
| Token owner | Client model | Passenger model |
| Duration | 7 days | 90 days (default) |
| Use case | Client portal | Trip itinerary page |
Magic Link Flow
Section titled “Magic Link Flow”1. Agent clicks "Share Trip with Pax" in Filament booking view | v2. Agent selects passengers (checkbox list) - Only passengers with email addresses shown - All selected by default - Lead passenger marked with badge | v3. For each selected passenger: - TripAccessTokenService creates Sanctum token - Previous tokens for same booking revoked - TripAccessNotification queued - Email sent with HMAC-signed magic link | v4. Passenger clicks email link -> /auth/trip?token={encoded} | v5. POST /api/trip/verify - Decodes base64 payload (token, booking_reference, passenger_id, signature) - Validates HMAC signature (prevents tampering) - Finds valid Sanctum token via TripAccessTokenService - Verifies booking_reference and passenger_id match token abilities - Marks token as used - Returns booking details + access_token | v6. Astro stores in server session: - bookingReference: string - accessToken: string (Sanctum token for subsequent calls) - expiresAt: timestamp - passengerName: string (for personalized greeting) | v7. Redirect to /{market}/trip/{reference} - Page shows personalized greeting - Trip details rendered from sessionAutomatic Sharing on First Payment
Section titled “Automatic Sharing on First Payment”Trip access emails are sent automatically the first time a booking transitions to a paid state, so admins no longer need to click “Share Trip with Pax” after every deposit.
Trigger point: PaymentService::updateBookingPaymentStatus() dispatches ShareTripWithPassengersJob after the initial-deposit transition (the branch that routes to PendingFlightBooking or PendingLandConfirmation). The balance-payment branch (AwaitingBalance -> FullyPaid) does not dispatch — the customer already received the email at deposit time.
Idempotency: bookings.trip_shared_at is the single source of truth. TripShareService stamps it on first successful send; the job and the dispatch site both short-circuit when it is already set, so the manual Filament action and the automatic job can coexist without duplicating emails.
Failure isolation: Per-passenger errors inside TripShareService are caught, reported via report() (Sentry) and Log::error(), and counted in the returned TripShareResult — one broken mailbox never blocks the rest, and the payment flow itself is never blocked because sending runs in the queued job.
Queue: ShareTripWithPassengersJob runs on the default queue (tries=3, timeout=120s, backoff=[30, 60, 180]). It is already covered by the existing compose queue worker — no compose changes were needed.
Source: backend/app/Jobs/ShareTripWithPassengersJob.php, backend/app/Services/TripShareService.php, backend/app/Services/Payment/PaymentService.php
Token Security
Section titled “Token Security”HMAC Signature
Section titled “HMAC Signature”The magic link payload includes an HMAC-SHA256 signature to prevent tampering:
$payload = [ 'token' => $plainTextToken, 'booking_reference' => $booking->booking_reference, 'passenger_id' => $passenger->id,];
$signature = hash_hmac('sha256', json_encode($payload), config('app.key'));The signature is verified before any database lookup, preventing timing attacks.
Sanctum Token Properties
Section titled “Sanctum Token Properties”| Property | Value | Purpose |
|---|---|---|
| Storage | personal_access_tokens table | Standard Sanctum polymorphic storage |
| Tokenable | Passenger model | Token owner is the passenger |
| Name | trip-access:{booking_reference} | Identifies token purpose and booking |
| Abilities | trip-access, booking:{id}, passenger:{id} | Scoped permissions |
| Expiry | 90 days default | Configurable via DEFAULT_VALIDITY_DAYS |
| Single-use | No | Token can be reused until expiry |
Token Abilities
Section titled “Token Abilities”Each token is created with abilities that encode the access context:
$abilities = [ 'trip-access', // Identifies as trip access token 'booking:' . $bookingId, // Links to specific booking 'passenger:' . $passengerId, // Links to specific passenger];Database Schema
Section titled “Database Schema”Trip access tokens use Laravel Sanctum’s personal_access_tokens table (polymorphic):
-- Sanctum's personal_access_tokens tableCREATE TABLE personal_access_tokens ( id BIGINT PRIMARY KEY, tokenable_type VARCHAR(255), -- 'App\Models\Passenger' for trip access tokenable_id BIGINT, -- Passenger ID name VARCHAR(255), -- 'trip-access:{booking_reference}' token VARCHAR(64) UNIQUE, -- Hashed token abilities JSON, -- ['trip-access', 'booking:123', 'passenger:456'] last_used_at TIMESTAMP NULL, expires_at TIMESTAMP NULL, created_at TIMESTAMP, updated_at TIMESTAMP,
INDEX(tokenable_type, tokenable_id));TripAccessTokenService
Section titled “TripAccessTokenService”The service wraps Sanctum token operations with booking context:
use App\Services\TripAccessTokenService;
$tokenService = app(TripAccessTokenService::class);
// Create token for passenger$tokenData = $tokenService->createForPassenger($booking, $passenger);// Returns: ['token' => NewAccessToken, 'plain_text' => 'id|token']
// Find valid token$token = $tokenService->findValidToken($plainTextToken);// Returns: PersonalAccessToken or null
// Get booking ID from token abilities$bookingId = $tokenService->getBookingIdFromToken($token);
// Get passenger from token$passenger = $tokenService->getPassengerFromToken($token);
// Revoke tokens for specific booking$tokenService->revokeTokensForBooking($passenger, $booking);
// Revoke all trip tokens for passenger$tokenService->revokeAllTokens($passenger);API Endpoints
Section titled “API Endpoints”Verify Trip Access Token
Section titled “Verify Trip Access Token”POST /api/trip/verifyRate Limit: 10 requests/minute
Request:
{ "token": "base64_encoded_payload"}Response (success):
{ "success": true, "data": { "booking": { /* BookingDetailsResource */ }, "passenger": { "id": 4, "name": "John Doe" }, "access_token": "1|abc123def456...", "expires_at": "2026-03-29T15:41:27.000000Z" }}Error codes: invalid_token, expired_token
Source: backend/app/Http/Controllers/Api/Trip/AccessController.php:26
Get Trip Details
Section titled “Get Trip Details”GET /api/trip/show?token={access_token}Rate Limit: 60 requests/minute
Request: Sanctum token as query parameter.
Response: Fresh booking details for the authenticated passenger.
Source: backend/app/Http/Controllers/Api/Trip/AccessController.php:110
Filament Integration
Section titled “Filament Integration”Share Trip with Pax Action
Section titled “Share Trip with Pax Action”Located in ViewBooking.php, the action:
- Shows checkbox list of passengers with email addresses
- Displays passenger name, lead badge, and email
- Pre-selects all passengers
- Allows bulk select/deselect
- Sends emails to selected passengers only
Action::make('share_trip_with_pax') ->label('Share Trip with Pax') ->icon(Heroicon::OutlinedEnvelope) ->color('gray') ->form(fn (): array => $this->getShareTripForm()) ->action(fn (array $data) => $this->shareWithSelectedPassengers($data['passengers'] ?? []))Source: backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php:28
Dual Access Methods
Section titled “Dual Access Methods”The trip page supports two access methods:
| Method | URL Pattern | Use Case |
|---|---|---|
| Admin Preview | /{market}/trip/{ref}?preview={signed_token} | Short-lived (60 min) signed URL for backoffice preview |
| Passenger Session | /{market}/trip/{ref} | Long-lived session from magic link verification |
// Trip page access checkif (previewToken) { // Admin preview via signed URL booking = await getBookingByPreviewToken(previewToken, clientIp);} else if (tripAccess && tripAccess.bookingReference === reference) { // Passenger session access const result = await getTripDetails(tripAccess.accessToken); booking = result.data?.booking;}Source: frontend/src/pages/[market]/trip/[reference].astro:46
Email Template
Section titled “Email Template”TripAccessNotification renders the designed Blade template resources/views/emails/post-booking.blade.php via MailMessage::view(...) (ported from the original HTML design at backend/emails/post-booking.html, delivered by PR #1982 and refreshed in PR #2021 / issue #2027). It replaces the previous bare-bones MailMessage with greeting + button.
Subject and visible copy are Spanish-only at this stage:
- With a tour title:
"Tu reserva está confirmada — {tour title}" - Without a tour title:
"Tu reserva está confirmada"
Dynamic data passed to the view
Section titled “Dynamic data passed to the view”| View var | Source |
|---|---|
firstName | Passenger::$first_name |
localizador | Booking::$booking_reference |
dates | Offer::getTravelDates() → outbound departure day → return arrival-home day of the bound international flight, formatted d/m/Y; falls back to Offer::$departure_date / duration-based Offer::getReturnDate() when no international flight is bound (land-only / pre-flight), and to departure only when there is no return. Flight-aware so long-haul return legs spanning an extra travel day show the day the traveler actually lands (ref #2081) |
destination | ProductByMarket::getPrimaryCountryName() (falls back to tour title, then —) |
passengers | Booking::$passengers — looped in the “Resumen de la reserva” card |
tripUrl | Per-passenger HMAC-signed magic link generated by generateTripAccessUrl() |
heroUrl | Per-tour hero asset URL — see below |
assets | Static asset URL prefix built from config('app.url') by resolveAssetsBaseUrl() |
contactPhone | VolareEntity::current()->phone (defaults to +34 919 49 45 52 when unset) |
contactEmail | Hardcoded to reservas@byvolare.com — a reservations-specific address, intentionally distinct from VolareEntity->email (the entity’s general contact address) |
whatsappUrl | Real WhatsApp link https://wa.me/34655547584 |
Per-tour hero image
Section titled “Per-tour hero image”The email hero comes from a new nullable product_templates.mail_hero_image column (migration 2026_05_28_151730_add_mail_hero_image_to_product_templates_table.php). Admins upload a GIF/PNG/JPG in two places:
- The Filament
EditSupplierTourMediapage - The “Visual & Media” step of the supplier-tour wizard (
SupplierTourForm)
Uploads land in the product-templates/mail-hero/ directory. TripAccessNotification::resolveHeroUrl() returns the stored value as-is when it’s already an absolute URL, otherwise resolves it via Storage::disk(config('filesystems.default'))->url($path). When the column is null it returns null and the hero block is omitted from the email — there is no generic fallback image (a tour-specific default would be misleading).
Static design assets
Section titled “Static design assets”Logos, icons, social, titulos, and sello live under backend/public/emails/transaccional/ and are served at {APP_URL}/emails/transaccional/.... The template references them via the $assets prefix.
Layout notes
Section titled “Layout notes”The refreshed design (PR #2021 / issue #2027) restructured the template:
- The top “Si no ves bien el correo” display bar and the footer “See in browser” / “Unsuscribe” legal links were removed entirely; the notification no longer passes
webVersionUrlorunsubscribeUrl. - The Contacto section is now Email full-width on top, then Teléfono + WhatsApp two-up (the previous “Área Privada” card was removed).
- The “Próximos pasos” timeline went from 4 steps to 3 (the “60 días antes / pago restante” step was removed).
- Page background is now white; body copy was updated; social handles point at the production
volare_es/volāreaccounts. - The non-functional “DESCARGAR DOCUMENTO” CTA (and its “También puedes descargar…” copy) was removed (issue #2065) — there is no PDF voucher download flow, so the button is gone until one is built.
Placeholder links not yet wired
Section titled “Placeholder links not yet wired”None. The previously placeholder “DESCARGAR DOCUMENTO” CTA (documentUrl => '#') was removed in issue #2065; every link in the template now points at a real destination.
Source: backend/app/Notifications/TripAccessNotification.php, backend/resources/views/emails/post-booking.blade.php
Payment confirmation email
Section titled “Payment confirmation email”BookingPaymentConfirmedNotification is a separate customer-facing receipt sent once per booking the first time a successful payment lands. It is distinct from the trip-access magic-link email and goes to a single payer address — not multiplied per passenger. The notification renders the designed Blade template resources/views/emails/payment-confirmed.blade.php (ported from the original HTML design at backend/emails/confirmacion-pago.html, delivered by PR #1982 and refreshed in PR #2021 / issue #2027).
Subject and visible copy are Spanish-only at this stage:
- With a tour title:
"Confirmación de pago — {tour title}" - Without a tour title:
"Confirmación de pago"
Recipient resolution
Section titled “Recipient resolution”The email is sent to a single recipient. The notification is dispatched via Notification::route('mail', $recipient) so it never targets a Notifiable model directly:
booking.contact_email(captured at checkout) — primary.booking.client?->email— fallback whencontact_emailis blank.- Neither set — the dispatch is skipped, a
Log::warning('Payment confirmation email skipped: no recipient')is logged, and the surrounding payment flow is never blocked.
Trigger point and idempotency
Section titled “Trigger point and idempotency”Fires from PaymentService::updateBookingPaymentStatus() immediately after the existing ShareTripWithPassengersJob::dispatch() block, on the same initial-deposit transition (the branch that routes to PendingFlightBooking or PendingLandConfirmation). The balance-payment branch (AwaitingBalance → FullyPaid) does not dispatch — one receipt per booking, not per installment.
bookings.payment_confirmation_sent_at is the idempotency gate (migration 2026_05_28_165400_add_payment_confirmation_sent_at_to_bookings_table.php). It is stamped with now() after a successful send, and the dispatch site short-circuits when it is already set. It is intentionally independent of trip_shared_at so the two emails can be resent without coupling.
Dynamic data passed to the receipt view
Section titled “Dynamic data passed to the receipt view”| View var | Source |
|---|---|
firstName | First word of Client::$name; falls back to lead passenger’s first_name; final fallback "viajero" |
localizador | Booking::$booking_reference |
importe | Latest successful Payment::$amount + currency, formatted via PHP intl NumberFormatter('es_ES', CURRENCY) (e.g. "300,00 €"); falls back to "300,00 EUR" when intl is unavailable |
dates | Offer::getTravelDates() → outbound departure day → return arrival-home day of the bound international flight, formatted d/m/Y; falls back to Offer::$departure_date / duration-based Offer::getReturnDate() when no international flight is bound (land-only / pre-flight), and to departure only when there is no return. Flight-aware so long-haul return legs spanning an extra travel day show the day the traveler actually lands (ref #2081) |
destination | ProductByMarket::getPrimaryCountryName() (falls back to tour title, then —) |
passengers | Booking::$passengers — looped under the “Pasajeros” label in the “Resumen de la compra” card |
bannerUrl | Per-tour banner URL — see below |
assets | Static asset URL prefix built from config('app.url') (/emails/transaccional) |
Per-tour payment banner
Section titled “Per-tour payment banner”The banner reuses the product_templates pattern as the trip-access hero, but lives in its own column: mail_payment_image (migration 2026_05_28_165359_add_mail_payment_image_to_product_templates_table.php). Unlike mail_hero_image, this slot is static — PNG/JPG only, no GIFs. Admins upload it in two places, sitting right next to the mail_hero_image field:
- The Filament
EditSupplierTourMediapage (Visual & Media section) - The “Visual & Media” step of the supplier-tour wizard (
SupplierTourForm)
Uploads land in product-templates/mail-payment/. BookingPaymentConfirmedNotification::resolveBannerUrl() returns the stored value as-is when it’s already an absolute URL, otherwise resolves it via Storage::disk(config('filesystems.default'))->url($path). When the column is null it returns null and the banner is omitted from the email — there is no generic fallback image.
Receipt layout notes
Section titled “Receipt layout notes”The refreshed design (PR #2021 / issue #2027) also reworked the receipt:
- The top “Si no ves bien el correo” display bar and the footer “See in browser” / “Unsuscribe” legal links were removed entirely; the notification no longer passes
webVersionUrlorunsubscribeUrl. - The passenger list label was renamed “Nombre de los pasajeros” → “Pasajeros”.
- The payment-status copy is split into two paragraphs: “Tu primer pago ha sido procesado correctamente.” plus a reminder that the second-payment instructions arrive 60 days before departure.
- The invoice request address changed
facturacion@byvolare.com→facturas@byvolare.com; social handles point at the production accounts.
Source: backend/app/Notifications/BookingPaymentConfirmedNotification.php, backend/resources/views/emails/payment-confirmed.blade.php, backend/app/Services/Payment/PaymentService.php
Admin: Trip Share Status panel
Section titled “Admin: Trip Share Status panel”The Filament ViewBooking page shows a Trip Share Status section that surfaces, per booking, whether passengers have actually opened their trip-access link.
The section is composed by BookingTripShareSection (under app/Filament/Resources/Bookings/Schemas/) and reads its data from TripShareStatusService::forBooking(Booking $booking): array.
What it shows:
- Section header — derived from
bookings.trip_shared_at:Not shared yet.when the column is null.Shared on {date · time} · {X} of {N} opened the link.once at least one mail has been dispatched successfully.
- One row per booking passenger:
- Passenger name
- Email (or
—) - Access badge — the only reliable per-passenger signal we track today:
Opened on {date · time}(green) — the passenger’s Sanctum token has a non-nulllast_used_at.Not opened yet(gray) — a token exists for this passenger but it has never been used.—(gray) — no token row exists at all.
There is intentionally no per-passenger “Sent” badge. The Sanctum token is created before the mail dispatch inside TripShareService::shareWithPassengers, so the existence of a token is not a guarantee that the mail actually went out. A proper “delivered” signal would require integrating mail-provider webhooks (Resend events). The booking-level trip_shared_at is reliable (TripShareService only stamps it when sent > 0), which is what powers the section header.
The header counts opened rows by filtering the same forBooking() result it already holds — no extra openedCount() call. Two queries still fire per render (one for the section header, one for the row table), which is acceptable for an admin page.
Source: backend/app/Services/TripShareStatusService.php, backend/app/Filament/Resources/Bookings/Schemas/BookingTripShareSection.php
Rate Limiting
Section titled “Rate Limiting”| Endpoint | Limit | Purpose |
|---|---|---|
POST /api/trip/verify | 10/min | Prevent brute force |
GET /api/trip/show | 60/min | Normal usage |
| File | Purpose |
|---|---|
backend/app/Services/TripAccessTokenService.php | Token management service |
backend/app/Services/TripShareStatusService.php | Per-passenger access state for the admin panel |
backend/app/Models/Passenger.php | Uses HasApiTokens trait for Sanctum |
backend/app/Http/Controllers/Api/Trip/AccessController.php | API endpoints |
backend/app/Notifications/TripAccessNotification.php | Trip-access magic-link email notification |
backend/resources/views/emails/post-booking.blade.php | Designed Blade template rendered by the trip-access notification |
backend/emails/post-booking.html | Original trip-access design source (PR #1982) |
backend/app/Notifications/BookingPaymentConfirmedNotification.php | Payment receipt email notification |
backend/resources/views/emails/payment-confirmed.blade.php | Designed Blade template rendered by the payment-confirmation notification |
backend/emails/confirmacion-pago.html | Original payment-confirmation design source (PR #1982) |
backend/public/emails/transaccional/ | Runtime-served static assets (logos, icons, social, titulos, sello) |
bookings.payment_confirmation_sent_at column | Idempotency gate for the payment-confirmation email |
product_templates.mail_payment_image column | Per-tour static banner used by the payment-confirmation email |
backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php | Admin action |
backend/app/Filament/Resources/Bookings/Schemas/BookingTripShareSection.php | Admin “Trip Share Status” panel |
frontend/src/pages/auth/trip.astro | Magic link verification page |
frontend/src/pages/[market]/trip/[reference].astro | Trip itinerary page |
frontend/src/features/trip-access/services/tripAccessApi.ts | Frontend API client |
backend/routes/api.php | Route definitions (trip prefix) |
Security Considerations
Section titled “Security Considerations”Token Security Measures
Section titled “Token Security Measures”- HMAC verification: Payload signed with
app.key, prevents tampering - Sanctum validation: Token must exist and not be expired
- Abilities verification: Token abilities must contain correct booking/passenger IDs
- Activity logging: Token usage tracked via
last_used_at - Token rotation: Re-sharing revokes previous tokens for same booking/passenger
Access Scope
Section titled “Access Scope”- Single booking: Each token grants access to one booking only
- No modification: Read-only access to trip details
- No cascading: Compromised token doesn’t expose other bookings
- Isolated tokens: Tokens for different bookings are independent
URL Patterns
Section titled “URL Patterns”- Trip page at
/{market}/trip/{reference}is not publicly accessible - Requires either valid preview token OR active session from magic link
- Session tied to specific booking reference