*/ private const EAGER_LOADS = [ 'route', 'timeSlot', 'vehicleOptions', 'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination', 'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions', ]; public function __construct( private CreateBookingAction $createBookingAction, private CancelBookingAction $cancelBookingAction, ) {} public function index(Request $request): AnonymousResourceCollection { $openid = $request->attributes->get('fastapi_openid'); $query = Booking::query(); if ($openid !== null) { // FastAPI agent (JWT auth, no Laravel user) — scoped to the // verified token's own openid, never a client-supplied value, // so one agent session can't list another customer's bookings. $query->where('openid', $openid); } else { Gate::authorize('viewAny', Booking::class); $query->where('user_id', $request->user()->id); } $bookings = $query // Only bookings that actually have a completed payment — a // pending_payment booking never had money move, so it's noise // in a booking list, not a real reservation to show. ->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed)) // A round trip is two Booking rows (outbound + return leg, // linked via linked_booking_id — domain.md §2b), but it should // still surface once here, not as two separate list entries. // The outbound row's `linked_booking` already carries the // return leg's full detail (including vehicle_options). ->where('is_return_leg', false) ->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%')) ->with(self::EAGER_LOADS) ->latest() ->paginate(); return BookingResource::collection($bookings); } public function show(Request $request, Booking $booking): BookingResource { $openid = $request->attributes->get('fastapi_openid'); if ($openid !== null) { abort_if($booking->openid !== $openid, 404); } else { Gate::authorize('view', $booking); } return new BookingResource($booking->load(self::EAGER_LOADS)); } public function store(StoreBookingRequest $request): JsonResponse { $validated = $request->validated(); $openid = $request->attributes->get('fastapi_openid'); if ($openid === null) { Gate::authorize('create', Booking::class); } $selections = array_map( fn (array $selection) => new VehicleSelectionData( vehicleOption: VehicleOption::from($selection['vehicle_option']), passengerCount: $selection['passenger_count'], ), $validated['selections'], ); $isRoundTrip = $validated['is_round_trip'] ?? false; $returnSelections = $isRoundTrip ? array_map( fn (array $selection) => new VehicleSelectionData( vehicleOption: VehicleOption::from($selection['vehicle_option']), passengerCount: $selection['passenger_count'], ), $validated['return_selections'], ) : null; // The agent's own auth path always wins over anything a header could // claim; customer channels come from Device-Type, not a // client-supplied body field (BookingChannel::fromDeviceTypeHeader // already refuses to hand back Agent/Admin from a header value). $channel = $openid !== null ? BookingChannel::Agent : BookingChannel::fromDeviceTypeHeader($request->header('Device-Type')); $booking = $this->createBookingAction->handle(new CreateBookingData( evRouteId: $validated['ev_route_id'], departureTimeSlotId: $validated['departure_time_slot_id'], travelDate: $validated['travel_date'], selections: $selections, passengerName: $validated['passenger_name'], passengerPhone: $validated['passenger_phone'], notes: $validated['notes'] ?? null, pickupAddress: $validated['pickup_address'], dropoffAddress: $validated['dropoff_address'], createdByChannel: $channel, // A verified FastAPI JWT's own openid always wins over a // client-supplied one — a request can never claim a different // customer's identity than its own token proves. userId: $request->user()?->id, openid: $openid ?? $validated['openid'] ?? null, pickupLat: $validated['pickup_lat'] ?? null, pickupLng: $validated['pickup_lng'] ?? null, dropoffLat: $validated['dropoff_lat'] ?? null, dropoffLng: $validated['dropoff_lng'] ?? null, returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null, returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null, returnTravelDate: $validated['return_travel_date'] ?? null, returnSelections: $returnSelections, )); return (new BookingResource($booking->load(self::EAGER_LOADS))) ->response() ->setStatusCode(201); } public function cancel(Request $request, Booking $booking): BookingResource { Gate::authorize('cancel', $booking); $this->cancelBookingAction->handle($booking, $request->user()?->id); return new BookingResource($booking->load(self::EAGER_LOADS)); } }