import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException, } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody, } from "@nestjs/swagger"; import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; 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 { PassengerAdmin } from "../../common/passenger-guards"; @ApiTags("Booking") @Controller("bookings") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) 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 iamUserId = req.user?.id; if (!iamUserId) throw new UnauthorizedException(); return this.service.findByIamUserId(iamUserId, { search, status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); } @Get("by-device") @SetMetadata("isPublic", true) @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() @SetMetadata("isPublic", true) @ApiOperation({ 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 }) @ApiQuery({ name: "status", required: false }) @ApiQuery({ name: "returnLegStatus", required: false }) @ApiQuery({ name: "bookingType", required: false }) @ApiQuery({ name: "paymentStatus", required: false }) @ApiQuery({ name: "dateFrom", required: false }) @ApiQuery({ name: "dateTo", required: false }) @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) findAll( @Query("search") search?: string, @Query("status") status?: string, @Query("returnLegStatus") returnLegStatus?: string, @Query("bookingType") bookingType?: string, @Query("paymentStatus") paymentStatus?: string, @Query("dateFrom") dateFrom?: string, @Query("dateTo") dateTo?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { return this.service.findAll({ search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); } @Post("guest") @SetMetadata("isPublic", true) @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(@Req() req: any, @Body() dto: CreateGuestBookingDto) { return this.guestService.createGuestBooking(dto, req); } @Get("saved-passengers") @SetMetadata("isPublic", true) @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(@Req() req: any, @Body() dto: CreateBookingDto) { // Always resolve passengerId from the authenticated JWT — never trust the request body const iamUserId = req.user?.id; if (!iamUserId) throw new UnauthorizedException(); return this.service.create({ ...dto, passengerId: iamUserId }); } @Get(":id/usage") @SetMetadata("isPublic", true) @ApiOperation({ 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); } @Get("by-phone") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Find bookings by phone number (no auth required)", description: `Returns all bookings where the contact phone matches the provided number. Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX). Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.`, }) @ApiQuery({ name: "phone", required: true, description: "Phone number in local (09…) or international (+251…) format", }) @ApiQuery({ name: "status", required: false, description: "Filter by booking status", }) @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) @ApiResponse({ status: 200, description: "Paginated list of bookings for this phone number", }) @ApiResponse({ status: 400, description: "Phone number missing or invalid" }) findByPhone( @Query("phone") phone?: string, @Query("status") status?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { if (!phone?.trim()) throw new BadRequestException("Phone number is required"); const digits = phone.replace(/[^\d]/g, ""); if (digits.length < 7) throw new BadRequestException("Phone number is too short"); return this.service.findByPhone(phone.trim(), { status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); } @Get(":bookingRef") @SetMetadata("isPublic", true) @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(":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(@Req() req: any, @Body() dto: ModifyBookingDto) { return this.service.modify(dto, req.user?.id); } @Delete(":id") @PassengerAdmin() @ApiBearerAuth("IAM-auth") @ApiOperation({ description: "Permanently deletes a booking record", }) @ApiQuery({ name: "cascade", required: false, type: Boolean, description: "Force delete with all related data", }) @ApiResponse({ status: 200, description: "Booking deleted successfully" }) @ApiResponse({ status: 404, description: "Booking not found" }) delete(@Param("id") id: string, @Query("cascade") cascade?: string) { return this.service.delete(id, cascade === "true"); } @Patch(":id") @SetMetadata("isPublic", true) @ApiOperation({ 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); } @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(@Req() req: any, @Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) { return this.service.cancel(ref, dto.reason, req.user?.id); } }