feat: ( bookings ) add authenticated My Bookings history to the passenger portal

This commit is contained in:
Abubeker Yasin
2026-08-29 12:28:42 +03:00
parent 1026c3b273
commit 6551660e5f
10 changed files with 645 additions and 130 deletions

View File

@@ -22,7 +22,7 @@ import {
ApiBody,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { BookingsService } from "./bookings.service";
import { BookingsService, BookingScope } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
@ApiTags("Booking")
@Controller("bookings")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
@@ -66,6 +68,13 @@ export class BookingsController {
required: false,
description: "Filter by booking status",
})
@ApiQuery({
name: "scope",
required: false,
enum: ["upcoming", "past", "cancelled", "all"],
description:
"Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.",
})
@ApiQuery({ name: "page", required: false, description: "Page number" })
@ApiQuery({
name: "pageSize",
@@ -80,6 +89,7 @@ export class BookingsController {
@Req() req: any,
@Query("search") search?: string,
@Query("status") status?: string,
@Query("scope") scope?: BookingScope,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
@@ -88,6 +98,7 @@ export class BookingsController {
return this.service.findByIamUserId(iamUserId, {
search,
status,
scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});

View File

@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -67,8 +67,15 @@ interface BookingFilters {
dateTo?: string;
page?: number;
pageSize?: number;
/** Portal "My bookings" tabs. Only honoured by findByPassengerId. */
scope?: BookingScope;
}
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
/** Statuses that mean the reservation is off — used by the `cancelled` scope. */
const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED'];
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name);
@@ -87,16 +94,34 @@ export class BookingsService {
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findByPassengerId(passenger.id, filters);
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
* correctly, rather than the client filtering one page at a time. Note it filters on
* `schedule.departureAt` — the schedule's own origin departure — while each item's
* displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own
* boarding stop. They differ by the run time to that stop; that is close enough for a
* tab filter and avoids a correlated stopTimes query per row.
*/
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
@@ -104,22 +129,41 @@ export class BookingsService {
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
// validation error (a 500) rather than being ignored. Only accept real enum members.
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
} else if (scope === 'upcoming' || scope === 'past') {
// Don't clobber an explicit `status` filter — intersect with it.
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
where.schedule = {
...(where.schedule ?? {}),
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
};
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
orderBy,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
priceTier: { select: { priceMinor: true } },
// A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the
// portal needs this to show a "Rescheduled" chip alongside the real status.
reschedules: { where: { status: 'APPLIED' }, select: { id: true } },
},
}),
this.prisma.booking.count({ where }),
@@ -150,6 +194,21 @@ export class BookingsService {
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
// Seat/coach per passenger, so the history table can show a Seat / Coach column
// without a round trip to GET /bookings/:ref for every row. `leg` disambiguates
// outbound (1) from return (2) on a round trip.
seats: booking.seats.map((bs: any) => ({
leg: bs.leg ?? 1,
passengerName: bs.passengerName,
seatNumber: bs.seat?.seatNumber ?? null,
coachNumber: bs.seat?.coach?.number ?? null,
})),
rescheduled: ((booking as any).reschedules?.length ?? 0) > 0,
// These three let the portal apply the same coarse reschedule gate the booking
// detail page uses, without fetching each booking in full.
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
isPackageBooking: !!(booking as any).packageId,
contactPhone: (booking as any).contactPhone ?? null,
};
}),
meta: {