import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; import { IamGuard } from '../../common/iam-adapter'; @ApiTags('Booking') @Controller('bookings') export class BookingsController { constructor( private service: BookingsService, private guestService: GuestBookingService, ) {} @Get('my') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get logged-in user\'s booking history', description: 'Returns all bookings for the authenticated user with schedule and payment details' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) @ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' }) getMyBookings( @Req() req: any, @Query('search') search?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { const passengerId = req.user?.passengerId; if (!passengerId) throw new Error('Passenger ID not found in token'); return this.service.findByPassengerId(passengerId, { search, status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); } @Get('by-device') @ApiOperation({ summary: 'Get bookings by device ID', description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' }) @ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) @ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' }) @ApiResponse({ status: 400, description: 'Device ID is required' }) getByDevice( @Query('deviceId') deviceId?: string, @Query('search') search?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { if (!deviceId) throw new BadRequestException('Device ID is required'); return this.service.findByDeviceId(deviceId, { search, status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); } @Get() @ApiOperation({ summary: 'List all bookings with filters (Admin/Agent)', description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) findAll( @Query('search') search?: string, @Query('status') status?: string, @Query('returnLegStatus') returnLegStatus?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.findAll({ search, status, returnLegStatus, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); } @Post('guest') @ApiOperation({ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', description: `Creates a booking without requiring login. Supports all four booking types. **bookingType: ONE_WAY (default)** - scheduleId, holdId, originStationId, destinationStationId, seatClassId - passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … } **bookingType: ROUND_TRIP** - Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId - passengers[]: each must include returnSeatId (seat on the return leg) **bookingType: TRANSIT** - scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId - passengers[]: each must include leg2SeatId **bookingType: ROUND_TRIP_TRANSIT** - All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId - passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId **Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised. **Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.` }) @ApiBody({ type: CreateGuestBookingDto, examples: { ONE_WAY: { summary: 'ONE_WAY — single direct journey (guest)', value: { scheduleId: 'schedule-uuid', holdId: 'hold-uuid', originStationId: 'station-uuid', destinationStationId: 'station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ONE_WAY', displayCurrency: 'ETB', passengers: [{ seatId: 'seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', phone: '+251911234567', email: 'abebe@email.com', }], savePassengerDetails: true, deviceId: 'device-uuid-123', }, }, ROUND_TRIP: { summary: 'ROUND_TRIP — outbound + return, single PNR (guest)', value: { scheduleId: 'outbound-schedule-uuid', holdId: 'outbound-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'djibouti-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ROUND_TRIP', returnScheduleId: 'return-schedule-uuid', returnHoldId: 'return-hold-uuid', returnOriginStationId: 'djibouti-station-uuid', returnDestinationStationId: 'addis-station-uuid', returnSeatClassId: 'seat-class-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'outbound-seat-uuid', returnSeatId: 'return-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', phone: '+251911234567', }], savePassengerDetails: true, deviceId: 'device-uuid-123', }, }, TRANSIT: { summary: 'TRANSIT — connecting train, single PNR (guest)', value: { scheduleId: 'leg1-schedule-uuid', holdId: 'leg1-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'diredawa-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'TRANSIT', leg2ScheduleId: 'leg2-schedule-uuid', leg2HoldId: 'leg2-hold-uuid', transitStationId: 'diredawa-station-uuid', leg2DestinationStationId: 'djibouti-station-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'leg1-seat-uuid', leg2SeatId: 'leg2-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', phone: '+251911234567', }], deviceId: 'device-uuid-123', }, }, ROUND_TRIP_TRANSIT: { summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)', value: { scheduleId: 'ob-leg1-schedule-uuid', holdId: 'ob-leg1-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'diredawa-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ROUND_TRIP_TRANSIT', leg2ScheduleId: 'ob-leg2-schedule-uuid', leg2HoldId: 'ob-leg2-hold-uuid', transitStationId: 'diredawa-station-uuid', leg2DestinationStationId: 'djibouti-station-uuid', returnScheduleId: 'ret-leg1-schedule-uuid', returnHoldId: 'ret-leg1-hold-uuid', returnOriginStationId: 'djibouti-station-uuid', returnDestinationStationId: 'diredawa-station-uuid', returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', returnLeg2HoldId: 'ret-leg2-hold-uuid', returnTransitStationId: 'diredawa-station-uuid', returnLeg2DestinationStationId: 'addis-station-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'ob-leg1-seat-uuid', leg2SeatId: 'ob-leg2-seat-uuid', returnSeatId: 'ret-leg1-seat-uuid', returnLeg2SeatId: 'ret-leg2-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', phone: '+251911234567', }], deviceId: 'device-uuid-123', }, }, }, }) @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) createGuest(@Body() dto: CreateGuestBookingDto) { return this.guestService.createGuestBooking(dto); } @Get('saved-passengers') @ApiOperation({ summary: 'Get saved passenger profiles', description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' }) @ApiResponse({ status: 200, description: 'List of saved passenger profiles' }) getSavedPassengers(@Query() query: GetSavedPassengersDto) { return this.guestService.getSavedPassengers(undefined, query.deviceId); } @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT', description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required. **ONE_WAY** - scheduleId, holdId, originStationId, destinationStationId, seatClassId - passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … } **ROUND_TRIP** - Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId - passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … } - Combined fare = outbound fare + return fare; single promo/loyalty deduction **TRANSIT** (connecting train, single PNR) - scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId - passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … } **ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train) - All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId - passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … } - 4 holds required, 4 seat sets per passenger, single PNR, single payment **Age-Based Pricing (all types)** - ADULT (≥5 years): full fare per leg - CHILD (<5 years): first child FREE per booking, subsequent children full fare` }) @ApiBody({ type: CreateBookingDto, examples: { ONE_WAY: { summary: 'ONE_WAY — single direct journey', value: { passengerId: 'passenger-uuid', scheduleId: 'schedule-uuid', holdId: 'hold-uuid', originStationId: 'station-uuid', destinationStationId: 'station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ONE_WAY', displayCurrency: 'ETB', passengers: [{ seatId: 'seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', }], }, }, ROUND_TRIP: { summary: 'ROUND_TRIP — outbound + return, single PNR', value: { passengerId: 'passenger-uuid', scheduleId: 'outbound-schedule-uuid', holdId: 'outbound-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'djibouti-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ROUND_TRIP', returnScheduleId: 'return-schedule-uuid', returnHoldId: 'return-hold-uuid', returnOriginStationId: 'djibouti-station-uuid', returnDestinationStationId: 'addis-station-uuid', returnSeatClassId: 'seat-class-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'outbound-seat-uuid', returnSeatId: 'return-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', }], }, }, TRANSIT: { summary: 'TRANSIT — connecting train, single PNR', value: { passengerId: 'passenger-uuid', scheduleId: 'leg1-schedule-uuid', holdId: 'leg1-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'diredawa-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'TRANSIT', leg2ScheduleId: 'leg2-schedule-uuid', leg2HoldId: 'leg2-hold-uuid', transitStationId: 'diredawa-station-uuid', leg2DestinationStationId: 'djibouti-station-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'leg1-seat-uuid', leg2SeatId: 'leg2-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', }], }, }, ROUND_TRIP_TRANSIT: { summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds', value: { passengerId: 'passenger-uuid', scheduleId: 'ob-leg1-schedule-uuid', holdId: 'ob-leg1-hold-uuid', originStationId: 'addis-station-uuid', destinationStationId: 'diredawa-station-uuid', seatClassId: 'seat-class-uuid', bookingType: 'ROUND_TRIP_TRANSIT', leg2ScheduleId: 'ob-leg2-schedule-uuid', leg2HoldId: 'ob-leg2-hold-uuid', transitStationId: 'diredawa-station-uuid', leg2DestinationStationId: 'djibouti-station-uuid', returnScheduleId: 'ret-leg1-schedule-uuid', returnHoldId: 'ret-leg1-hold-uuid', returnOriginStationId: 'djibouti-station-uuid', returnDestinationStationId: 'diredawa-station-uuid', returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', returnLeg2HoldId: 'ret-leg2-hold-uuid', returnTransitStationId: 'diredawa-station-uuid', returnLeg2DestinationStationId: 'addis-station-uuid', displayCurrency: 'ETB', passengers: [{ seatId: 'ob-leg1-seat-uuid', leg2SeatId: 'ob-leg2-seat-uuid', returnSeatId: 'ret-leg1-seat-uuid', returnLeg2SeatId: 'ret-leg2-seat-uuid', passengerName: 'Abebe Kebede', dateOfBirth: '1990-05-15', idDocumentType: 'NATIONAL_ID', idDocumentNumber: 'ET123456789', nationality: 'Ethiopian', }], }, }, }, }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' }) @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); } @Get(':bookingRef') @ApiOperation({ summary: 'Get booking details by reference (no auth required)', description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.' }) @ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' }) @ApiResponse({ status: 404, description: 'Booking not found' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } @Patch(':id') @ApiOperation({ summary: 'Update booking details', description: 'Updates booking information for admin/agent operations' }) @ApiResponse({ status: 200, description: 'Booking updated successfully' }) @ApiResponse({ status: 404, description: 'Booking not found' }) update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } @Patch(':bookingRef/modify') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Modify booking seats or trip', description: 'Allows modification of confirmed bookings before departure' }) @ApiResponse({ status: 200, description: 'Booking modified successfully' }) @ApiResponse({ status: 400, description: 'Cannot modify cancelled or past bookings' }) modify(@Body() dto: ModifyBookingDto) { return this.service.modify(dto); } @Delete(':id') @ApiOperation({ summary: 'Delete booking (admin only)', description: 'Permanently deletes a booking record' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @ApiResponse({ status: 404, description: 'Booking not found' }) delete(@Param('id') id: string) { return this.service.delete(id); } @Get(':id/usage') @ApiOperation({ summary: 'Check if booking is in use', description: 'Returns list of modules/data that reference this booking' }) @ApiResponse({ status: 200, description: 'Usage information retrieved' }) @ApiResponse({ status: 404, description: 'Booking not found' }) checkUsage(@Param('id') id: string) { return this.service.checkBookingUsage(id); } @Delete(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Cancel booking with refund', description: 'Cancels booking and processes refund (80% for confirmed bookings)' }) @ApiResponse({ status: 200, description: 'Booking cancelled with refund amount' }) @ApiResponse({ status: 400, description: 'Booking already cancelled' }) cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) { return this.service.cancel(ref, dto.reason); } }