diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts new file mode 100644 index 000000000..5a0fb50b1 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -0,0 +1,45 @@ +/** + * Resolves the booking/check-in cutoff for one boarding stop: stop-level + * `RouteStop.checkinMinutesBefore` override wins, else the route-level default + * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with + * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching + * that stop), not its departure or the schedule's overall origin departure — a downstream + * stop's cutoff must be independent of how long ago the train left its origin. The first stop + * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. + * + * Single source of truth for this computation — SeatsService.holdSeats and + * SearchService.buildScheduleResult already applied it (search results only ever showed a + * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking + * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin + * departure, which could reject a booking the search/hold steps had just accepted under the + * route's actual configured cutoff. + */ +export interface CheckinCutoff { + /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ + segmentTime: Date; + /** Minutes before segmentTime that booking/holding closes. */ + checkinMinutes: number; + /** The moment booking/holding closes for this stop. */ + cutoffAt: Date; +} + +export function resolveCheckinCutoff( + schedule: { + departureAt: Date; + route?: { + checkinMinutesBefore?: number | null; + stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>; + } | null; + }, + stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, + stationId: string | null | undefined, +): CheckinCutoff { + const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; + const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; + const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; + return { + segmentTime, + checkinMinutes, + cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000), + }; +} diff --git a/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts new file mode 100644 index 000000000..2fcc6581b --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts @@ -0,0 +1,37 @@ +/** + * Resolves a booking's actual boarding/alighting station AND time for one leg from + * originStationId/destinationStationId (set when the booking covers only part of a + * longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via + * the schedule's stopTimes — falling back to the schedule's own full-route + * station/time when there's no segment override (older records, or a booking that + * covers the whole run). + * + * Single source of truth for this resolution — station-only lookups used to be + * duplicated ad hoc across bookings/tickets/notifications while the departureAt/ + * arrivalAt kept being read straight off the schedule (the train's full-route span), + * which showed the wrong boarding/alighting time for any stop-based booking. + */ +export interface ResolvedSegment { + origin: any; + destination: any; + departureAt: any; + arrivalAt: any; +} + +export function resolveBookingSegment( + schedule: any, + originStationId: string | null | undefined, + destinationStationId: string | null | undefined, +): ResolvedSegment { + const stopTimes: any[] = schedule?.stopTimes ?? []; + const findStop = (stationId: string | null | undefined) => + stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; + const originStop = findStop(originStationId); + const destStop = findStop(destinationStationId); + return { + origin: originStop?.station ?? schedule?.originStation ?? null, + destination: destStop?.station ?? schedule?.destinationStation ?? null, + departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, + arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, + }; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b75f1eeb4..98a54528b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -140,7 +141,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -148,31 +149,34 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), - currency: booking.displayCurrency, - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - adultCount: booking.adultCount, - childCount: booking.childCount, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - createdAt: booking.createdAt, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -270,7 +274,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, seats: { select: { id: true } }, priceTier: { select: { priceMinor: true } }, @@ -294,7 +298,9 @@ export class BookingsService { this.prisma.packageBooking.count({ where: pkgWhere }), ]); - const mappedBookings = items.map(booking => ({ + const mappedBookings = items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -309,14 +315,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, payment: booking.paymentIntent ?? undefined, seatCount: booking.seats.length, - })); + }; + }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, @@ -397,7 +404,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -405,9 +412,11 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -422,14 +431,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, - })), + }; + }), meta: { page, pageSize, @@ -532,7 +542,7 @@ export class BookingsService { orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -554,9 +564,10 @@ export class BookingsService { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, @@ -567,11 +578,11 @@ export class BookingsService { passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, - schedule: booking.schedule ? { + schedule: segment ? { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, } : null, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, @@ -715,16 +726,15 @@ export class BookingsService { verifaydaVerified: s.verifaydaVerified, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null, })), - schedule: { - train: booking.schedule.train, - originStation: (booking as any).originStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation) - : booking.schedule.originStation, - destinationStation: (booking as any).destinationStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation) - : booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, + schedule: (() => { + const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + }; + })(), paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; @@ -1850,35 +1860,6 @@ export class BookingsService { ); } - // Resolves the passenger's actual boarding/alighting stations AND times for one leg - // from originStationId/destinationStationId (set when the booking covers only part of - // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via - // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/ - // times when there's no segment override (older records, or a booking that covers the - // whole run). Station resolution mirrors notifications.service.ts's - // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt - // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt - // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page, - // confirmation) to the same behavior search results already have, instead of always - // showing the train's full-route span. - private resolveSegmentStations( - schedule: any, - originStationId: string | null | undefined, - destinationStationId: string | null | undefined, - ): { origin: any; destination: any; departureAt: any; arrivalAt: any } { - const stopTimes: any[] = schedule?.stopTimes ?? []; - const findStop = (stationId: string | null | undefined) => - stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; - const originStop = findStop(originStationId); - const destStop = findStop(destinationStationId); - return { - origin: originStop?.station ?? schedule?.originStation ?? null, - destination: destStop?.station ?? schedule?.destinationStation ?? null, - departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, - arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, - }; - } - async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ @@ -1987,13 +1968,13 @@ export class BookingsService { if (refreshed) Object.assign(booking, refreshed); } - const outboundSegment = this.resolveSegmentStations( + const outboundSegment = resolveBookingSegment( (booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId, ); const returnSegment = (booking as any).returnSchedule - ? this.resolveSegmentStations( + ? resolveBookingSegment( (booking as any).returnSchedule, (booking as any).returnOriginStationId, (booking as any).returnDestinationStationId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index d40824c36..3e2a225e6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; -/** Booking cutoff: reject new bookings within this many ms of departure. */ -const BOOKING_CUTOFF_MS = 30 * 60 * 1000; +/** + * Throws if the given boarding stop's own configurable check-in cutoff (route/stop + * checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has + * passed. Must be checked against the actual boarding stop, not the schedule's origin — a + * downstream stop's cutoff is independent of how long ago the train left its origin. + */ +function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void { + const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId); + if (Date.now() >= cutoffAt.getTime()) { + throw new BadRequestException( + `Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`, + ); + } +} function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -107,20 +120,22 @@ export class GuestBookingService { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + route: { include: { stops: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); - if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + // Cut off relative to the passenger's actual boarding stop, using the same + // configurable per-stop/route checkinMinutesBefore that already gated the seat hold + // and the search result — not a separate, hardcoded 30 minutes off the train's origin. + assertWithinCheckinCutoff(schedule, originStop, dto.originStationId); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; @@ -391,7 +406,7 @@ export class GuestBookingService { const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, @@ -401,10 +416,6 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); - if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const synth = (sched: any, stationId: string, seq: number) => { const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; return { stationId, sequence: seq, station }; @@ -418,6 +429,10 @@ export class GuestBookingService { if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId); + const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; @@ -690,7 +705,7 @@ export class GuestBookingService { const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, @@ -700,10 +715,6 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); - if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -711,6 +722,10 @@ export class GuestBookingService { if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId); + // Process passengers (verify identity once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; @@ -894,7 +909,7 @@ export class GuestBookingService { if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ - this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -904,10 +919,6 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); - if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -921,6 +932,10 @@ export class GuestBookingService { if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId); + // Process passengers (verify once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index a1678d131..12f549e9f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -353,27 +354,6 @@ export class NotificationsService { } } - /** - * Resolves the user's actual boarding/alighting stations from the booking's originStationId / - * destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when - * the booking has no segment override (e.g. older records or packages). - */ - private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } { - const s = booking?.schedule ?? {}; - const stopTimes: any[] = s.stopTimes ?? []; - const findStation = (stationId: string | null | undefined, fallback: any) => { - if (stationId && stopTimes.length > 0) { - const stop = stopTimes.find((st: any) => st.stationId === stationId); - if (stop?.station) return stop.station; - } - return fallback ?? null; - }; - return { - originStation: findStation(booking?.originStationId, s.originStation), - destinationStation: findStation(booking?.destinationStationId, s.destinationStation), - }; - } - /** * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get @@ -400,17 +380,17 @@ export class NotificationsService { // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. const passengerName = seats[0]?.passengerName ?? 'Passenger'; const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); return { passengerName, bookingRef: ref, - origin: originSt?.name ?? '', - destination: destSt?.name ?? '', + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', trainSeatLines, - travelDate: fmtDate(s.departureAt), - departureTime: fmtTime(s.departureAt), - arrivalTime: fmtTime(s.arrivalAt), + travelDate: fmtDate(segment.departureAt), + departureTime: fmtTime(segment.departureAt), + arrivalTime: fmtTime(segment.arrivalAt), payLink, }; } @@ -509,12 +489,12 @@ export class NotificationsService { private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { const s = booking.schedule ?? {}; - const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD'; const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return [ `Booking ${booking.bookingRef} confirmed.`, - `${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`, + `${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`, `Train: ${s.train?.name ?? s.train?.number ?? ''}`, `Departs: ${dep}`, passengers ? `Passengers: ${passengers}` : '', @@ -527,7 +507,9 @@ export class NotificationsService { const s = booking.schedule ?? {}; const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const originSt = segment.origin; + const destSt = segment.destination; const seatRows = (booking.seats ?? []) .map((bs: any) => { const coach = bs.seat?.coach?.number ?? '-'; @@ -568,11 +550,11 @@ export class NotificationsService {