Merge pull request #959 from Tria-plc/tests

Adding exact reason why train is not available to users search results
This commit is contained in:
mulish77
2026-07-25 12:29:41 +03:00
committed by GitHub
6 changed files with 485 additions and 71 deletions

View File

@@ -590,8 +590,22 @@ export class GuestBookingService {
// Release the reservation using the SAME scope it was created with (global vs
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
// block; reimplementing that here would risk missing that reset.
// block; reimplementing that here would risk missing that reset. This alone leaves a
// window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the
// latter is only created on payment success — see PaymentsService.createJourneySegments)
// — i.e. fully available to the public — the instant this returns, since confirmSeats()
// is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the
// seat with the same createdBy segment-range metadata the search/hold-conflict checks
// already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the
// real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays
// unavailable to everyone else until the passenger pays or the hold/booking expires.
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
await this.seatsService.holdSeats({
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: [{ passengerId: guestPassengerId, seatId }],
});
await this.seatsService.confirmSeats([seatId]);
this.eventEmitter.emit('booking.created', { booking });

View File

@@ -12,6 +12,7 @@ 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";
import { Passenger } from "@edr/types";
const POINTS_TO_MINOR = 10;
@@ -102,19 +103,23 @@ export class SearchService {
const outbound = [...direct, ...transit];
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
const [alternativesOutbound, outboundReason] = await Promise.all([
this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
]);
return {
journeyType: "ONE_WAY",
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
outboundReason,
};
}
@@ -157,7 +162,7 @@ export class SearchService {
const returnDate = dto.returnDate ?? dto.date;
if (outbound.length === 0 || inbound.length === 0) {
const [alternativeOutbound, alternativeInbound] = await Promise.all([
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
outbound.length === 0
? this.searchAlternatives(
dto.originStationId,
@@ -178,6 +183,12 @@ export class SearchService {
dto.nationality,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
: Promise.resolve(undefined),
]);
return {
journeyType: "ROUND_TRIP",
@@ -187,6 +198,8 @@ export class SearchService {
alternativeInbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
outboundReason,
inboundReason,
};
}
@@ -344,6 +357,101 @@ export class SearchService {
);
}
/**
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
* everything. Priority order (most specific/actionable first): a station pair EDR never
* connects at all beats "nothing on this exact date", which beats "something exists but
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
*/
private async classifyEmptySearch(
originStationId: string,
destinationStationId: string,
dateStr: string,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
]);
const originStationName = origin?.name ?? "the origin station";
const destinationStationName = destination?.name ?? "the destination station";
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
code,
originStationName,
destinationStationName,
});
// 1. Does any active route connect these two stations, in this direction, at all —
// ignoring date entirely?
const candidateRoutes = await this.prisma.route.findMany({
where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } },
});
const routeExists = candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
// 2. A route exists — is there any schedule at all on the requested date for this pair
// (regardless of status/package/coach/cutoff — those are checked next)?
const [y, m, d] = dateStr.split("-").map(Number);
const date = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
const dayCandidates = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
select: {
status: true,
isPackageOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
coachAssignments: { select: { id: true } },
},
});
const sameDayForPair = dayCandidates.filter((s) => {
const o = s.stopTimes.find((st) => st.stationId === originStationId);
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
return !!o && !!dst && o.sequence < dst.sequence;
});
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter(
(s) =>
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.coachAssignments.length > 0,
);
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
if (sameDayForPair.every((s) => s.isPackageOnly))
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
// for this origin stop?
const allCutoffPassed = bookable.every((s) => {
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
});
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
// searchSchedules dropped it is zero/insufficient seat availability.
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
}
// ── Transit search ─────────────────────────────────────────────────────────
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;

View File

@@ -294,6 +294,37 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
});
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
// no JourneySegment (only created on payment success) the instant the payment link went
// out — fully bookable by the general public before the traveler had even paid.
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-4",
);
expect(result.booking.status).toBe("PENDING_PAYMENT");
// A member of the public trying to hold the exact same seat/leg must be rejected —
// proves the seat is covered by a real SeatHold (or equivalent), not silently free.
await expect(
seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
} as any),
).rejects.toThrow(/already (held|booked)/i);
});
it("requires a phone number for a PASSENGER booking", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);

View File

@@ -25,6 +25,7 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
import { useState, useEffect } from "react";
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
import { Passenger } from "@edr/types";
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
// modal, so both pick the same icon for a given coach type name.
@@ -35,6 +36,73 @@ const getCoachIcon = (typeName: string) => {
return Armchair;
};
function formatSearchDate(dateStr: string): string {
if (!dateStr) return "your selected date";
try {
return format(new Date(`${dateStr}T00:00:00`), "MMM d, yyyy");
} catch {
return dateStr;
}
}
// Turns the /search response's outboundReason/inboundReason (absent on older responses,
// or when the emptiness cause couldn't be classified) into copy for the empty-state cards
// below. `showAlternatives` gates whether an alternative-dates calendar makes sense — a
// station pair EDR never serves can't be fixed by picking a different date.
function emptyReasonCopy(
reason: Passenger.ISearchEmptyReason | undefined,
dateStr: string,
): { title: string; message: string; showAlternatives: boolean } {
const date = formatSearchDate(dateStr);
if (!reason) {
return {
title: "No trains available",
message: `No trains available on ${date}. Please choose another date below.`,
showAlternatives: true,
};
}
const { code, originStationName, destinationStationName } = reason;
switch (code) {
case Passenger.SearchEmptyReasonCode.NoRoute:
return {
title: "Route not available",
message: `EDR doesn't currently run trains between ${originStationName} and ${destinationStationName}.`,
showAlternatives: false,
};
case Passenger.SearchEmptyReasonCode.FullyBooked:
return {
title: "Fully booked",
message: `This train is fully booked on ${date}. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.Cancelled:
return {
title: "Departure cancelled",
message: `The ${date} departure between ${originStationName} and ${destinationStationName} was cancelled. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.CheckinClosed:
return {
title: "Booking closed for today",
message: `Booking for the ${date} departure has closed. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.PackageOnly:
return {
title: "Only a travel package is available",
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
default:
return {
title: "No trains available",
message: `No trains available on ${date}. Please choose another date below.`,
showAlternatives: true,
};
}
}
export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -1097,18 +1165,35 @@ export default function ResultsPage() {
(results?.alternativeOutbound || []).length === 0 &&
(results?.alternativeInbound || []).length === 0;
if (isRoundTripNoResults) {
const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date);
const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date);
const sameReason =
!!results?.outboundReason &&
results?.outboundReason?.code === results?.inboundReason?.code;
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains found for your selected dates or route.</span>
<div className="flex flex-col gap-2 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>
{sameReason
? outboundCopy.message
: "No trains found for your selected dates or route."}
</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
{!sameReason && (results?.outboundReason || results?.inboundReason) && (
<div className="pl-6 space-y-1 text-sm text-red-800 dark:text-red-300">
<p>Outbound: {outboundCopy.message}</p>
<p>Return: {inboundCopy.message}</p>
</div>
)}
</div>
</div>
</div>
@@ -1125,6 +1210,8 @@ export default function ResultsPage() {
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date);
const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date);
return (
<div className="booking-page">
@@ -1137,50 +1224,62 @@ export default function ResultsPage() {
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
No trains available on your selected dates. Please choose another
date below.
</p>
<div className="space-y-4 text-left">
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
label="Outbound date"
alternatives={alternativeOutbound}
value={outboundValue}
minDate={new Date()}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
return;
}
setPendingOutboundDate(date);
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative outbound dates found nearby.
<div className="space-y-5 text-left">
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1">
Outbound {outboundCopy.title}
</p>
)}
{alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
label="Return date"
alternatives={alternativeInbound}
value={inboundValue}
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
return;
}
setPendingInboundDate(date);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative return dates found nearby.
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{outboundCopy.message}</p>
{outboundCopy.showAlternatives ? (
alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
label="Outbound date"
alternatives={alternativeOutbound}
value={outboundValue}
minDate={new Date()}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
return;
}
setPendingOutboundDate(date);
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative outbound dates found nearby.
</p>
)
) : null}
</div>
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1">
Return {inboundCopy.title}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{inboundCopy.message}</p>
{inboundCopy.showAlternatives ? (
alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
label="Return date"
alternatives={alternativeInbound}
value={inboundValue}
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
return;
}
setPendingInboundDate(date);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative return dates found nearby.
</p>
)
) : null}
</div>
</div>
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
@@ -1201,6 +1300,10 @@ export default function ResultsPage() {
}
if (isOneWayNoOutbound) {
const { title, message, showAlternatives } = emptyReasonCopy(
results?.outboundReason,
searchData.date,
);
return (
<div className="booking-page">
{renderClassModal()}
@@ -1211,26 +1314,34 @@ export default function ResultsPage() {
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
No trains available
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
No trains available on your selected date. Please choose another
date below.
{message}
</p>
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
{showAlternatives ? (
alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
) : (
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button>
)
) : (
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
Modify search
</button>
)}
</div>