mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
Cut off time configuration
This commit is contained in:
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
|||||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
@@ -140,7 +141,7 @@ export class BookingsService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
@@ -148,31 +149,34 @@ export class BookingsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(booking => ({
|
items: items.map(booking => {
|
||||||
id: booking.id,
|
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||||
bookingRef: booking.bookingRef,
|
return {
|
||||||
status: booking.status,
|
id: booking.id,
|
||||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
bookingRef: booking.bookingRef,
|
||||||
currency: booking.displayCurrency,
|
status: booking.status,
|
||||||
displayCurrency: booking.displayCurrency,
|
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
currency: booking.displayCurrency,
|
||||||
adultCount: booking.adultCount,
|
displayCurrency: booking.displayCurrency,
|
||||||
childCount: booking.childCount,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
bookingType: booking.bookingType,
|
adultCount: booking.adultCount,
|
||||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
childCount: booking.childCount,
|
||||||
createdAt: booking.createdAt,
|
bookingType: booking.bookingType,
|
||||||
schedule: {
|
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||||
train: booking.schedule.train,
|
createdAt: booking.createdAt,
|
||||||
originStation: booking.schedule.originStation,
|
schedule: {
|
||||||
destinationStation: booking.schedule.destinationStation,
|
train: booking.schedule.train,
|
||||||
departureAt: booking.schedule.departureAt,
|
originStation: segment.origin,
|
||||||
arrivalAt: booking.schedule.arrivalAt,
|
destinationStation: segment.destination,
|
||||||
},
|
departureAt: segment.departureAt,
|
||||||
paymentIntent: booking.paymentIntent,
|
arrivalAt: segment.arrivalAt,
|
||||||
seatCount: booking.seats.length,
|
},
|
||||||
})),
|
paymentIntent: booking.paymentIntent,
|
||||||
|
seatCount: booking.seats.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -270,7 +274,7 @@ export class BookingsService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
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 } },
|
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
|
||||||
seats: { select: { id: true } },
|
seats: { select: { id: true } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
@@ -294,7 +298,9 @@ export class BookingsService {
|
|||||||
this.prisma.packageBooking.count({ where: pkgWhere }),
|
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,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
@@ -309,14 +315,15 @@ export class BookingsService {
|
|||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
schedule: {
|
schedule: {
|
||||||
train: booking.schedule.train,
|
train: booking.schedule.train,
|
||||||
originStation: booking.schedule.originStation,
|
originStation: segment.origin,
|
||||||
destinationStation: booking.schedule.destinationStation,
|
destinationStation: segment.destination,
|
||||||
departureAt: booking.schedule.departureAt,
|
departureAt: segment.departureAt,
|
||||||
arrivalAt: booking.schedule.arrivalAt,
|
arrivalAt: segment.arrivalAt,
|
||||||
},
|
},
|
||||||
payment: booking.paymentIntent ?? undefined,
|
payment: booking.paymentIntent ?? undefined,
|
||||||
seatCount: booking.seats.length,
|
seatCount: booking.seats.length,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mappedPkg = pkgItems.map((b: any) => ({
|
const mappedPkg = pkgItems.map((b: any) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
@@ -397,7 +404,7 @@ export class BookingsService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
@@ -405,9 +412,11 @@ export class BookingsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
@@ -422,14 +431,15 @@ export class BookingsService {
|
|||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
schedule: {
|
schedule: {
|
||||||
train: booking.schedule.train,
|
train: booking.schedule.train,
|
||||||
originStation: booking.schedule.originStation,
|
originStation: segment.origin,
|
||||||
destinationStation: booking.schedule.destinationStation,
|
destinationStation: segment.destination,
|
||||||
departureAt: booking.schedule.departureAt,
|
departureAt: segment.departureAt,
|
||||||
arrivalAt: booking.schedule.arrivalAt,
|
arrivalAt: segment.arrivalAt,
|
||||||
},
|
},
|
||||||
paymentIntent: booking.paymentIntent,
|
paymentIntent: booking.paymentIntent,
|
||||||
seatCount: booking.seats.length,
|
seatCount: booking.seats.length,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -532,7 +542,7 @@ export class BookingsService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
passenger: { select: { id: true, iamUserId: true } },
|
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,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
@@ -554,9 +564,10 @@ export class BookingsService {
|
|||||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
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 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 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 {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
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,
|
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))],
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
passengers: uniquePassengers,
|
passengers: uniquePassengers,
|
||||||
schedule: booking.schedule ? {
|
schedule: segment ? {
|
||||||
train: booking.schedule.train,
|
train: booking.schedule.train,
|
||||||
originStation: booking.schedule.originStation,
|
originStation: segment.origin,
|
||||||
destinationStation: booking.schedule.destinationStation,
|
destinationStation: segment.destination,
|
||||||
departureAt: booking.schedule.departureAt,
|
departureAt: segment.departureAt,
|
||||||
} : null,
|
} : null,
|
||||||
paymentIntent: booking.paymentIntent,
|
paymentIntent: booking.paymentIntent,
|
||||||
seatCount: booking.seats.length,
|
seatCount: booking.seats.length,
|
||||||
@@ -715,16 +726,15 @@ export class BookingsService {
|
|||||||
verifaydaVerified: s.verifaydaVerified,
|
verifaydaVerified: s.verifaydaVerified,
|
||||||
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
|
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
|
||||||
})),
|
})),
|
||||||
schedule: {
|
schedule: (() => {
|
||||||
train: booking.schedule.train,
|
const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||||
originStation: (booking as any).originStationId
|
return {
|
||||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
|
train: booking.schedule.train,
|
||||||
: booking.schedule.originStation,
|
originStation: segment.origin,
|
||||||
destinationStation: (booking as any).destinationStationId
|
destinationStation: segment.destination,
|
||||||
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
|
departureAt: segment.departureAt,
|
||||||
: booking.schedule.destinationStation,
|
};
|
||||||
departureAt: booking.schedule.departureAt,
|
})(),
|
||||||
},
|
|
||||||
paymentIntent: booking.paymentIntent,
|
paymentIntent: booking.paymentIntent,
|
||||||
seatCount: booking.seats.length,
|
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) {
|
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 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({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
@@ -1987,13 +1968,13 @@ export class BookingsService {
|
|||||||
if (refreshed) Object.assign(booking, refreshed);
|
if (refreshed) Object.assign(booking, refreshed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboundSegment = this.resolveSegmentStations(
|
const outboundSegment = resolveBookingSegment(
|
||||||
(booking as any).schedule,
|
(booking as any).schedule,
|
||||||
(booking as any).originStationId,
|
(booking as any).originStationId,
|
||||||
(booking as any).destinationStationId,
|
(booking as any).destinationStationId,
|
||||||
);
|
);
|
||||||
const returnSegment = (booking as any).returnSchedule
|
const returnSegment = (booking as any).returnSchedule
|
||||||
? this.resolveSegmentStations(
|
? resolveBookingSegment(
|
||||||
(booking as any).returnSchedule,
|
(booking as any).returnSchedule,
|
||||||
(booking as any).returnOriginStationId,
|
(booking as any).returnOriginStationId,
|
||||||
(booking as any).returnDestinationStationId,
|
(booking as any).returnDestinationStationId,
|
||||||
|
|||||||
@@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
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 {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
@@ -107,20 +120,22 @@ export class GuestBookingService {
|
|||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
|
route: { include: { stops: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
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)
|
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
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 segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||||
|
|
||||||
@@ -391,7 +406,7 @@ export class GuestBookingService {
|
|||||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||||
this.prisma.trainSchedule.findUnique({
|
this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: dto.scheduleId },
|
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({
|
this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: dto.returnScheduleId },
|
where: { id: dto.returnScheduleId },
|
||||||
@@ -401,10 +416,6 @@ export class GuestBookingService {
|
|||||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||||
if (!returnSchedule) throw new NotFoundException('Return 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 synth = (sched: any, stationId: string, seq: number) => {
|
||||||
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
|
||||||
return { stationId, sequence: seq, station };
|
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 (!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');
|
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 outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
|
||||||
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
||||||
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
||||||
@@ -690,7 +705,7 @@ export class GuestBookingService {
|
|||||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||||
this.prisma.trainSchedule.findUnique({
|
this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: dto.scheduleId },
|
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({
|
this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: dto.leg2ScheduleId },
|
where: { id: dto.leg2ScheduleId },
|
||||||
@@ -700,10 +715,6 @@ export class GuestBookingService {
|
|||||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 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 leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
const leg2OriginStop = leg2Schedule.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 (!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');
|
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)
|
// Process passengers (verify identity once)
|
||||||
const passengersData: any[] = [];
|
const passengersData: any[] = [];
|
||||||
let adultCount = 0, childCount = 0;
|
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');
|
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
|
||||||
|
|
||||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
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.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.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' } } } }),
|
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 (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 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 obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
const obL2Origin = obL2Sched.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 (!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');
|
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)
|
// Process passengers (verify once)
|
||||||
const passengersData: any[] = [];
|
const passengersData: any[] = [];
|
||||||
let adultCount = 0, childCount = 0;
|
let adultCount = 0, childCount = 0;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters';
|
|||||||
import { EmailClientService } from './email-client.service';
|
import { EmailClientService } from './email-client.service';
|
||||||
import { SmsClientService } from './sms-client.service';
|
import { SmsClientService } from './sms-client.service';
|
||||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||||
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
|
|
||||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
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
|
* 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
|
* 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.
|
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
||||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
||||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
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 {
|
return {
|
||||||
passengerName,
|
passengerName,
|
||||||
bookingRef: ref,
|
bookingRef: ref,
|
||||||
origin: originSt?.name ?? '',
|
origin: segment.origin?.name ?? '',
|
||||||
destination: destSt?.name ?? '',
|
destination: segment.destination?.name ?? '',
|
||||||
trainSeatLines,
|
trainSeatLines,
|
||||||
travelDate: fmtDate(s.departureAt),
|
travelDate: fmtDate(segment.departureAt),
|
||||||
departureTime: fmtTime(s.departureAt),
|
departureTime: fmtTime(segment.departureAt),
|
||||||
arrivalTime: fmtTime(s.arrivalAt),
|
arrivalTime: fmtTime(segment.arrivalAt),
|
||||||
payLink,
|
payLink,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -509,12 +489,12 @@ export class NotificationsService {
|
|||||||
|
|
||||||
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
|
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
|
||||||
const s = booking.schedule ?? {};
|
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 passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
|
||||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
|
||||||
return [
|
return [
|
||||||
`Booking ${booking.bookingRef} confirmed.`,
|
`Booking ${booking.bookingRef} confirmed.`,
|
||||||
`${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`,
|
`${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`,
|
||||||
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
||||||
`Departs: ${dep}`,
|
`Departs: ${dep}`,
|
||||||
passengers ? `Passengers: ${passengers}` : '',
|
passengers ? `Passengers: ${passengers}` : '',
|
||||||
@@ -527,7 +507,9 @@ export class NotificationsService {
|
|||||||
const s = booking.schedule ?? {};
|
const s = booking.schedule ?? {};
|
||||||
const fmt = (d: any) =>
|
const fmt = (d: any) =>
|
||||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
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 ?? [])
|
const seatRows = (booking.seats ?? [])
|
||||||
.map((bs: any) => {
|
.map((bs: any) => {
|
||||||
const coach = bs.seat?.coach?.number ?? '-';
|
const coach = bs.seat?.coach?.number ?? '-';
|
||||||
@@ -568,11 +550,11 @@ export class NotificationsService {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:8px 0;color:#666;">Departs</td>
|
<td style="padding:8px 0;color:#666;">Departs</td>
|
||||||
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
|
<td style="padding:8px 0;text-align:right;">${fmt(segment.departureAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:8px 0;color:#666;">Arrives</td>
|
<td style="padding:8px 0;color:#666;">Arrives</td>
|
||||||
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
|
<td style="padding:8px 0;text-align:right;">${fmt(segment.arrivalAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -636,12 +618,12 @@ export class NotificationsService {
|
|||||||
const fmt = (d: any) =>
|
const fmt = (d: any) =>
|
||||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||||
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
|
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
|
||||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||||
const origin = originSt?.name ?? '';
|
const origin = segment.origin?.name ?? '';
|
||||||
const dest = destSt?.name ?? '';
|
const dest = segment.destination?.name ?? '';
|
||||||
const train = s.train?.name ?? s.train?.number ?? '';
|
const train = s.train?.name ?? s.train?.number ?? '';
|
||||||
const dep = fmt(s.departureAt);
|
const dep = fmt(segment.departureAt);
|
||||||
const arr = fmt(s.arrivalAt);
|
const arr = fmt(segment.arrivalAt);
|
||||||
|
|
||||||
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
|
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
|
||||||
name: bs.passengerName ?? '',
|
name: bs.passengerName ?? '',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { CurrencyService } from "../currency/currency.service";
|
|||||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||||
import { SegmentsService } from "../segments/segments.service";
|
import { SegmentsService } from "../segments/segments.service";
|
||||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||||
|
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||||
import { Currency, Prisma } from "@prisma/client";
|
import { Currency, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
@@ -554,23 +555,11 @@ export class SearchService {
|
|||||||
|
|
||||||
// Segment-level cutoff: use the origin stop's own estimated arrival time, not the
|
// Segment-level cutoff: use the origin stop's own estimated arrival time, not the
|
||||||
// schedule's overall departureAt (which is station A's time). This lets
|
// schedule's overall departureAt (which is station A's time). This lets
|
||||||
// B→D remain bookable even after A→D closes. The first stop has no arrival (nothing
|
// B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
|
||||||
// to arrive at), so it falls back to its own departure.
|
// route default → 30 min fallback — same resolution GuestBookingService applies at
|
||||||
// Cutoff resolution: stop-level override → route default → 30 min fallback.
|
// booking-creation time, so a segment shown as bookable here stays bookable through
|
||||||
const now = new Date();
|
// checkout instead of being rejected against a different, hardcoded cutoff.
|
||||||
const segmentDepartureAt =
|
if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
|
||||||
originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt;
|
|
||||||
const routeStop = schedule.route?.stops?.find(
|
|
||||||
(s) => s.stationId === originStationId,
|
|
||||||
);
|
|
||||||
const checkinMinutes =
|
|
||||||
routeStop?.checkinMinutesBefore ??
|
|
||||||
schedule.route?.checkinMinutesBefore ??
|
|
||||||
30;
|
|
||||||
if (
|
|
||||||
segmentDepartureAt.getTime() - now.getTime() <=
|
|
||||||
checkinMinutes * 60 * 1000
|
|
||||||
)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Collect all valid seat IDs upfront for a single batch availability check
|
// Collect all valid seat IDs upfront for a single batch availability check
|
||||||
@@ -682,8 +671,11 @@ export class SearchService {
|
|||||||
nationality,
|
nationality,
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
);
|
);
|
||||||
const legDepartureAt = schedule.departureAt;
|
// Use the selected stop's own planned time, not the schedule's full-route span —
|
||||||
const legArrivalAt = schedule.arrivalAt;
|
// for stop-based (mid-route) boarding/alighting these differ from the train's
|
||||||
|
// overall origin departure / final destination arrival.
|
||||||
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
const displayCurrency =
|
const displayCurrency =
|
||||||
faresByClass[0]?.displayCurrency ??
|
faresByClass[0]?.displayCurrency ??
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
interface OfflineValidation {
|
interface OfflineValidation {
|
||||||
@@ -129,6 +130,7 @@ export class TicketsService {
|
|||||||
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||||
|
|
||||||
|
|
||||||
|
const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId);
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
ticketNumber: t.barcodePayload,
|
ticketNumber: t.barcodePayload,
|
||||||
@@ -152,20 +154,14 @@ export class TicketsService {
|
|||||||
contactPhone: t.booking?.contactPhone,
|
contactPhone: t.booking?.contactPhone,
|
||||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||||
seats: t.booking?.seats ?? [],
|
seats: t.booking?.seats ?? [],
|
||||||
originStation: (() => {
|
originStation: segment.origin,
|
||||||
const id = t.booking?.originStationId;
|
destinationStation: segment.destination,
|
||||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
|
||||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
|
||||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
|
||||||
})(),
|
|
||||||
destinationStation: (() => {
|
|
||||||
const id = t.booking?.destinationStationId;
|
|
||||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
|
||||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
|
||||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
|
||||||
})(),
|
|
||||||
},
|
},
|
||||||
schedule: t.booking?.schedule,
|
schedule: t.booking?.schedule ? {
|
||||||
|
...t.booking.schedule,
|
||||||
|
departureAt: segment.departureAt,
|
||||||
|
arrivalAt: segment.arrivalAt,
|
||||||
|
} : null,
|
||||||
seat: t.seat ? {
|
seat: t.seat ? {
|
||||||
id: t.seat.id,
|
id: t.seat.id,
|
||||||
seatNumber: t.seat.seatNumber,
|
seatNumber: t.seat.seatNumber,
|
||||||
@@ -643,11 +639,14 @@ export class TicketsService {
|
|||||||
throw new NotFoundException('No ticket found for this booking');
|
throw new NotFoundException('No ticket found for this booking');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if ticket date matches today
|
// Check if ticket date matches today. Boarding window is relative to the
|
||||||
|
// passenger's actual boarding stop, not the train's origin — for a mid-route
|
||||||
|
// boarding these differ.
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||||
if ((booking as any).schedule?.departureAt) {
|
|
||||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
if (boardingSegment.departureAt) {
|
||||||
|
const departureTime = new Date(boardingSegment.departureAt);
|
||||||
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
|
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
|
||||||
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
|
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
@@ -673,18 +672,6 @@ export class TicketsService {
|
|||||||
// Send notifications after successful boarding
|
// Send notifications after successful boarding
|
||||||
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
||||||
|
|
||||||
// Resolve user-selected segment rather than the full schedule route
|
|
||||||
const _schedStops = (booking as any).schedule?.stopTimes ?? [];
|
|
||||||
const _resolveStation = (id: string | null | undefined, fallback: any) => {
|
|
||||||
if (id) {
|
|
||||||
const found = _schedStops.find((st: any) => st.stationId === id)?.station;
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation);
|
|
||||||
const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||||
@@ -693,11 +680,11 @@ export class TicketsService {
|
|||||||
ticketNumber: ticket.barcodePayload,
|
ticketNumber: ticket.barcodePayload,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||||
route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`,
|
route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`,
|
||||||
seat: seatNumber,
|
seat: seatNumber,
|
||||||
coach: coachNumber,
|
coach: coachNumber,
|
||||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||||
departureTime: (booking as any).schedule?.departureAt,
|
departureTime: boardingSegment.departureAt,
|
||||||
boardedAt: result.validatedAt,
|
boardedAt: result.validatedAt,
|
||||||
leg: result.leg || 'OUTBOUND',
|
leg: result.leg || 'OUTBOUND',
|
||||||
bookingType: booking.bookingType,
|
bookingType: booking.bookingType,
|
||||||
|
|||||||
@@ -33,20 +33,13 @@ async function createTestSchedule(
|
|||||||
const train = await harness.prisma.train.create({
|
const train = await harness.prisma.train.create({
|
||||||
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
});
|
});
|
||||||
const schedule = await schedules.createSchedule({
|
|
||||||
trainId: train.id,
|
|
||||||
routeId: IDS.route,
|
|
||||||
departureAt: opts.departureAt.toISOString(),
|
|
||||||
arrivalAt: opts.arrivalAt.toISOString(),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Give the schedule a bookable coach so holdSeats has real seats to work with.
|
// createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's
|
||||||
|
// "must have at least one coach assigned" guard) — the coach has to exist and be passed
|
||||||
|
// via coachIds BEFORE creation, not attached afterward.
|
||||||
const coach = await harness.prisma.coach.create({
|
const coach = await harness.prisma.coach.create({
|
||||||
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
});
|
});
|
||||||
await harness.prisma.coachAssignment.create({
|
|
||||||
data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true },
|
|
||||||
});
|
|
||||||
const seats = await Promise.all(
|
const seats = await Promise.all(
|
||||||
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
|
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
|
||||||
harness.prisma.seat.create({
|
harness.prisma.seat.create({
|
||||||
@@ -55,6 +48,14 @@ async function createTestSchedule(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const schedule = await schedules.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
|
||||||
return { schedule, seats };
|
return { schedule, seats };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* Stop-based (mid-route) booking — segment correctness suite.
|
||||||
|
*
|
||||||
|
* Regression coverage for three bugs reported against live stop-based bookings:
|
||||||
|
*
|
||||||
|
* 1. Search results (SearchService.buildScheduleResult) showed the train's overall
|
||||||
|
* departure/arrival instead of the selected origin/destination stop's own time — e.g.
|
||||||
|
* searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in
|
||||||
|
* resolving the boarding/alighting STATION correctly for a mid-route segment while still
|
||||||
|
* reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also
|
||||||
|
* used by BookingsService, TicketsService, NotificationsService) — see
|
||||||
|
* src/common/utils/segment-resolver.utils.ts.
|
||||||
|
* 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin
|
||||||
|
* departure regardless of where the passenger actually boards, so a schedule whose origin
|
||||||
|
* had already departed >30min ago wrongly blocked booking a downstream segment that
|
||||||
|
* hadn't closed yet.
|
||||||
|
* 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30
|
||||||
|
* minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff
|
||||||
|
* that SeatsService.holdSeats and the search step already enforce. A passenger who passed
|
||||||
|
* the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly
|
||||||
|
* rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes
|
||||||
|
* of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff()
|
||||||
|
* utility as SeatsService.holdSeats and SearchService — see
|
||||||
|
* src/common/utils/checkin-cutoff.utils.ts.
|
||||||
|
*
|
||||||
|
* Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual
|
||||||
|
* cumulative travel-time interpolation in SchedulesService.createSchedule, same as
|
||||||
|
* checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService
|
||||||
|
* are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ),
|
||||||
|
* so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the
|
||||||
|
* Tier-2 pattern in money-integrity.e2e-spec.ts.
|
||||||
|
*/
|
||||||
|
import { IdDocumentType } from "@prisma/client";
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { SegmentsService } from "../src/modules/segments/segments.service";
|
||||||
|
import { SearchService } from "../src/modules/search/search.service";
|
||||||
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
|
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||||
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host —
|
||||||
|
* matches search.service.ts's "+03:00" date-matching window). */
|
||||||
|
function localDateStr(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stop-based booking — segment time & cutoff correctness", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let searchService: SearchService;
|
||||||
|
let bookingsService: BookingsService;
|
||||||
|
let guestBookingService: GuestBookingService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const currencyService = harness.moduleRef.get(CurrencyService);
|
||||||
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
|
const segmentsService = new SegmentsService(harness.prisma as any);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
|
searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService);
|
||||||
|
// holdSeats() itself never touches segmentsService (only getSeatMap/availability-map
|
||||||
|
// callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts.
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
bookingsService = new BookingsService(
|
||||||
|
harness.prisma as any,
|
||||||
|
asyncStub(), // dataSource
|
||||||
|
seatsService,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
asyncStub(), // verifaydaService
|
||||||
|
currencyService,
|
||||||
|
fareEngine,
|
||||||
|
asyncStub(), // auditService
|
||||||
|
);
|
||||||
|
guestBookingService = new GuestBookingService(
|
||||||
|
harness.prisma as any,
|
||||||
|
seatsService,
|
||||||
|
asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID
|
||||||
|
currencyService,
|
||||||
|
asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs
|
||||||
|
fareEngine,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation
|
||||||
|
* (createSchedule now rejects a schedule with zero coaches). */
|
||||||
|
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) {
|
||||||
|
const train = await harness.prisma.train.create({
|
||||||
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
|
});
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({
|
||||||
|
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const schedule = await schedulesService.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
coachIds: [coach.id],
|
||||||
|
} as any);
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function foreignPassenger(seatId: string) {
|
||||||
|
return {
|
||||||
|
seatId,
|
||||||
|
passengerName: "Test Passenger",
|
||||||
|
dateOfBirth: "1990-01-01",
|
||||||
|
idDocumentType: IdDocumentType.PASSPORT,
|
||||||
|
passportNumber: "X123456",
|
||||||
|
passportCountry: "Djibouti",
|
||||||
|
nationality: "Djiboutian",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("search results (SearchService.searchTrips)", () => {
|
||||||
|
it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
|
||||||
|
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const result: any = await searchService.searchTrips({
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
date: localDateStr(dep),
|
||||||
|
adultCount: 1,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
|
||||||
|
expect(found).toBeTruthy();
|
||||||
|
|
||||||
|
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
|
||||||
|
expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||||
|
// Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback.
|
||||||
|
expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
|
||||||
|
const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const result: any = await searchService.searchTrips({
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
date: localDateStr(dep),
|
||||||
|
adultCount: 1,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
|
||||||
|
expect(found).toBeTruthy();
|
||||||
|
|
||||||
|
const expectedBArrival = new Date(dep.getTime() + 60 * 60_000);
|
||||||
|
expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime());
|
||||||
|
// Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback.
|
||||||
|
expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => {
|
||||||
|
it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
// A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the
|
||||||
|
// passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 190 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const hold = await seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const booking: any = await guestBookingService.createGuestBooking({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
holdId: (hold as any).holdId,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [foreignPassenger(seats[0].id)],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(booking.bookingRef).toBeTruthy();
|
||||||
|
expect(booking.originStationId).toBe(IDS.stationB);
|
||||||
|
expect(booking.destinationStationId).toBe(IDS.stationC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
// B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created
|
||||||
|
// directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based
|
||||||
|
// cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 50 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const hold = await harness.prisma.seatHold.create({
|
||||||
|
data: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
seatIds: [seats[0].id],
|
||||||
|
passengerId: "77777777-7777-4777-8777-777777777777",
|
||||||
|
expiresAt: new Date(Date.now() + 10 * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.createGuestBooking({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
holdId: hold.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [foreignPassenger(seats[0].id)],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/not accepted within 30 minutes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => {
|
||||||
|
// Regression for the reported bug: /booking/review still rejected a booking with
|
||||||
|
// "not accepted within 30 minutes of departure" even after the passenger passed the
|
||||||
|
// earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to
|
||||||
|
// enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route
|
||||||
|
// .checkinMinutesBefore. B's own configured cutoff here is 10 minutes.
|
||||||
|
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
// B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff,
|
||||||
|
// but outside B's own configured 10-min cutoff.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 60 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const hold = await seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const booking: any = await guestBookingService.createGuestBooking({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
holdId: (hold as any).holdId,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [foreignPassenger(seats[0].id)],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(booking.bookingRef).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
// B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff
|
||||||
|
// (would have wrongly been allowed), but inside B's own configured 90-min cutoff.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 80 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const hold = await harness.prisma.seatHold.create({
|
||||||
|
data: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
seatIds: [seats[0].id],
|
||||||
|
passengerId: "99999999-9999-4999-8999-999999999999",
|
||||||
|
expiresAt: new Date(Date.now() + 10 * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guestBookingService.createGuestBooking({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
holdId: hold.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
seatClassId: IDS.seatClassLocal,
|
||||||
|
passengers: [foreignPassenger(seats[0].id)],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/not accepted within 90 minutes/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("booking detail & list segment resolution (BookingsService)", () => {
|
||||||
|
it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
|
||||||
|
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
const passenger = await harness.prisma.passenger.create({ data: {} });
|
||||||
|
const booking = await harness.prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: `SEGDET${Date.now()}`,
|
||||||
|
passengerId: passenger.id,
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
totalMinor: 10000,
|
||||||
|
displayCurrency: "ETB",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
|
||||||
|
|
||||||
|
const detail: any = await bookingsService.getByRef(booking.bookingRef);
|
||||||
|
expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||||
|
expect(detail.schedule.origin.id).toBe(IDS.stationB);
|
||||||
|
expect(detail.schedule.destination.id).toBe(IDS.stationC);
|
||||||
|
|
||||||
|
const list: any = await bookingsService.findByPassengerId(passenger.id);
|
||||||
|
expect(list.items).toHaveLength(1);
|
||||||
|
expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
|
||||||
|
expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -308,7 +308,12 @@ export default function RoutesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addStop = () => {
|
const addStop = () => {
|
||||||
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
|
// distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting
|
||||||
|
// every new stop to 0 made each one collide with the previous, so simply clicking "Add
|
||||||
|
// Intermediate Stop" a few times and saving without hand-editing every distance always failed
|
||||||
|
// validation. Default each new stop's distance a step past whatever precedes it instead.
|
||||||
|
const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0;
|
||||||
|
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeStop = (index: number) => {
|
const removeStop = (index: number) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user