mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
720 lines
26 KiB
TypeScript
720 lines
26 KiB
TypeScript
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,
|
|
IssueReservationBookingDto,
|
|
} from "./guest-booking.dto";
|
|
import { JwtGuard } from "../../common/jwt.guard";
|
|
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
|
|
|
@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);
|
|
}
|
|
|
|
@Post("reservations/:seatId/issue")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
|
@ApiBearerAuth("IAM-auth")
|
|
@ApiOperation({
|
|
summary: "Issue a booking from a reserved (blocked) seat",
|
|
description:
|
|
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.",
|
|
})
|
|
@ApiBody({ type: IssueReservationBookingDto })
|
|
issueBookingFromReservation(
|
|
@Param("seatId") seatId: string,
|
|
@Body() dto: IssueReservationBookingDto,
|
|
@Req() req: any,
|
|
) {
|
|
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
|
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
|
}
|
|
|
|
@Delete("reservations/:seatId")
|
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
|
@ApiBearerAuth("IAM-auth")
|
|
@ApiOperation({
|
|
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
|
description:
|
|
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
|
|
})
|
|
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
|
|
cancelReservationForSeat(
|
|
@Param("seatId") seatId: string,
|
|
@Query("scheduleId") scheduleId: string,
|
|
@Req() req: any,
|
|
) {
|
|
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
|
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
|
|
}
|
|
|
|
@Get("pay/:token")
|
|
@SetMetadata("isPublic", true)
|
|
@ApiOperation({
|
|
summary: "Resolve a reservation booking by its pay token (public)",
|
|
description:
|
|
"Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.",
|
|
})
|
|
getByPayToken(@Param("token") token: string) {
|
|
return this.service.getByPayToken(token);
|
|
}
|
|
|
|
@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 identity from the authenticated JWT — never trust the request body.
|
|
// Routed through the unified GuestBookingService: because req.user.id is present, it
|
|
// resolves the existing passenger from the token and layers on the authenticated-only
|
|
// behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard).
|
|
const iamUserId = req.user?.id;
|
|
if (!iamUserId) throw new UnauthorizedException();
|
|
return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|