mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Cut off time configuration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
</table>
|
||||
|
||||
@@ -636,12 +618,12 @@ export class NotificationsService {
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const origin = originSt?.name ?? '';
|
||||
const dest = destSt?.name ?? '';
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
const origin = segment.origin?.name ?? '';
|
||||
const dest = segment.destination?.name ?? '';
|
||||
const train = s.train?.name ?? s.train?.number ?? '';
|
||||
const dep = fmt(s.departureAt);
|
||||
const arr = fmt(s.arrivalAt);
|
||||
const dep = fmt(segment.departureAt);
|
||||
const arr = fmt(segment.arrivalAt);
|
||||
|
||||
const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({
|
||||
name: bs.passengerName ?? '',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CurrencyService } from "../currency/currency.service";
|
||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import { SegmentsService } from "../segments/segments.service";
|
||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||
import { Currency, Prisma } from "@prisma/client";
|
||||
|
||||
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
|
||||
// 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
|
||||
// to arrive at), so it falls back to its own departure.
|
||||
// Cutoff resolution: stop-level override → route default → 30 min fallback.
|
||||
const now = new Date();
|
||||
const segmentDepartureAt =
|
||||
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
|
||||
)
|
||||
// B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
|
||||
// route default → 30 min fallback — same resolution GuestBookingService applies at
|
||||
// booking-creation time, so a segment shown as bookable here stays bookable through
|
||||
// checkout instead of being rejected against a different, hardcoded cutoff.
|
||||
if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
|
||||
return null;
|
||||
|
||||
// Collect all valid seat IDs upfront for a single batch availability check
|
||||
@@ -682,8 +671,11 @@ export class SearchService {
|
||||
nationality,
|
||||
availabilityByClass,
|
||||
);
|
||||
const legDepartureAt = schedule.departureAt;
|
||||
const legArrivalAt = schedule.arrivalAt;
|
||||
// Use the selected stop's own planned time, not the schedule's full-route span —
|
||||
// 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 =
|
||||
faresByClass[0]?.displayCurrency ??
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
interface OfflineValidation {
|
||||
@@ -129,6 +130,7 @@ export class TicketsService {
|
||||
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||
|
||||
|
||||
const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId);
|
||||
return {
|
||||
id: t.id,
|
||||
ticketNumber: t.barcodePayload,
|
||||
@@ -152,20 +154,14 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
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;
|
||||
})(),
|
||||
originStation: segment.origin,
|
||||
destinationStation: segment.destination,
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
schedule: t.booking?.schedule ? {
|
||||
...t.booking.schedule,
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
} : null,
|
||||
seat: t.seat ? {
|
||||
id: t.seat.id,
|
||||
seatNumber: t.seat.seatNumber,
|
||||
@@ -643,11 +639,14 @@ export class TicketsService {
|
||||
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();
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||
|
||||
if (boardingSegment.departureAt) {
|
||||
const departureTime = new Date(boardingSegment.departureAt);
|
||||
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
|
||||
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
|
||||
|
||||
@@ -673,18 +672,6 @@ export class TicketsService {
|
||||
// Send notifications after successful boarding
|
||||
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 {
|
||||
success: true,
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
@@ -693,11 +680,11 @@ export class TicketsService {
|
||||
ticketNumber: ticket.barcodePayload,
|
||||
bookingRef: booking.bookingRef,
|
||||
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,
|
||||
coach: coachNumber,
|
||||
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,
|
||||
leg: result.leg || 'OUTBOUND',
|
||||
bookingType: booking.bookingType,
|
||||
|
||||
Reference in New Issue
Block a user