mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
feat: ( bookings ) add authenticated My Bookings history to the passenger portal
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user