Merge pull request #385 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-06-30 20:09:01 +03:00
committed by GitHub
7 changed files with 238 additions and 173 deletions

View File

@@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.getType() !== 'http') {
return true;
}
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
await Promise.all([ await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { import {
PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE, PAYMENT_EVENTS_EXCHANGE,
@@ -19,6 +20,7 @@ export class PaymentEventsConsumer {
constructor(private readonly paymentsService: PaymentsService) {} constructor(private readonly paymentsService: PaymentsService) {}
@IsPublic()
@RabbitSubscribe({ @RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE, exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*

View File

@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10; const POINTS_TO_MINOR = 10;
// Shape returned by the heavy schedule include used throughout this service
type ScheduleWithIncludes = {
id: string;
routeId: string | null;
departureAt: Date;
arrivalAt: Date;
status: string;
train: any;
originStation: any;
destinationStation: any;
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
};
const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
} as const;
@Injectable() @Injectable()
export class SearchService { export class SearchService {
constructor( constructor(
@@ -124,7 +148,7 @@ export class SearchService {
if (windowStart < now) windowStart.setTime(now.getTime()); if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate); const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
const totalPassengers = adultCount + (childCount ?? 0); const totalPassengers = adultCount + (childCount ?? 0);
@@ -139,30 +163,16 @@ export class SearchService {
], ],
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
include: { include: SCHEDULE_INCLUDE,
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
orderBy: { departureAt: 'asc' }, orderBy: { departureAt: 'asc' },
}); });
const results: any[] = []; const results = await Promise.all(
for (const schedule of schedules) { schedules.map(schedule =>
const result = await this.buildScheduleResult( this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
schedule, )
originStationId, );
destinationStationId, return results.filter(Boolean);
totalPassengers,
nationality,
);
if (result) results.push(result);
}
return results;
} }
private async searchSchedules( private async searchSchedules(
@@ -185,29 +195,18 @@ export class SearchService {
departureAt: { gte: date < now ? now : date, lt: nextDay }, departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
include: { include: SCHEDULE_INCLUDE,
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
}); });
const results: any[] = []; const results = await Promise.all(
for (const schedule of schedules) { schedules.map(schedule =>
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
if (result) results.push(result); )
} );
return results; return results.filter(Boolean);
} }
// ── Transit search ───────────────────────────────────────────────────────── // ── Transit search ─────────────────────────────────────────────────────────
// Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
// where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
// to change trains at the transit station.
private readonly MIN_CONNECTION_MINUTES = 30; private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360; private readonly MAX_CONNECTION_MINUTES = 360;
@@ -219,82 +218,67 @@ export class SearchService {
childCount?: number, childCount?: number,
nationality?: string, nationality?: string,
) { ) {
// Find all stations that can serve as transit points:
// they must be a stop after origin on some schedule AND
// a stop before destination on another schedule on the same day.
const [y, m, d] = dateStr.split('-').map(Number); const [y, m, d] = dateStr.split('-').map(Number);
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const totalPassengers = adultCount + (childCount ?? 0); const totalPassengers = adultCount + (childCount ?? 0);
// Load all schedules on this date that pass through origin // Load leg1 and all potential leg2 candidates in one parallel round-trip
const leg1Schedules = await this.prisma.trainSchedule.findMany({ // instead of firing a separate DB query per transit stop.
where: { const [leg1Schedules, allCandidates] = await Promise.all([
status: { in: ['SCHEDULED', 'BOARDING'] }, this.prisma.trainSchedule.findMany({
departureAt: { gte: dayStart, lt: dayEnd }, where: {
stopTimes: { some: { stationId: originStationId } }, status: { in: ['SCHEDULED', 'BOARDING'] },
}, departureAt: { gte: dayStart, lt: dayEnd },
include: { stopTimes: { some: { stationId: originStationId } },
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
}, },
}, include: SCHEDULE_INCLUDE,
}); }),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: leg2WindowEnd },
},
include: SCHEDULE_INCLUDE,
}),
]);
const results: any[] = []; const results: any[] = [];
for (const leg1 of leg1Schedules) { for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue; if (!originStop) continue;
// Every stop after origin on leg1 is a candidate transit station
const candidateTransitStops = leg1.stopTimes.filter( const candidateTransitStops = leg1.stopTimes.filter(
(s: any) => s.sequence > originStop.sequence, s => s.sequence > originStop.sequence,
); );
for (const transitStop of candidateTransitStops) { for (const transitStop of candidateTransitStops) {
// leg1 must NOT already contain the final destination const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); if (leg1HasDest) continue;
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
const transitStationId = transitStop.stationId; const transitStationId = transitStop.stationId;
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
// Find leg2 schedules departing from the transit station within the connection window,
// and reaching the final destination. Search up to the next calendar day to handle
// overnight connections.
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const leg2Schedules = await this.prisma.trainSchedule.findMany({ // Filter from pre-loaded candidates in memory — no extra DB query
where: { const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
status: { in: ['SCHEDULED', 'BOARDING'] }, const dep = new Date(s.departureAt).getTime();
departureAt: { gte: connWindowStart, lte: connWindowEnd }, return dep >= connWindowStart.getTime()
stopTimes: { some: { stationId: transitStationId } }, && dep <= connWindowEnd.getTime()
}, && s.stopTimes.some(st => st.stationId === transitStationId);
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
}); });
for (const leg2 of leg2Schedules) { for (const leg2 of leg2Schedules) {
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
if (!leg2TransitStop || !leg2DestStop) continue; if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
// Build individual leg result objects (reuse existing per-schedule logic)
const [leg1Result, leg2Result] = await Promise.all([ const [leg1Result, leg2Result] = await Promise.all([
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
@@ -326,7 +310,6 @@ export class SearchService {
displayCurrency, displayCurrency,
combinedMinFareMinor, combinedMinFareMinor,
combinedMinFareDisplay, combinedMinFareDisplay,
// Convenience top-level fields so round-trip filter can read them uniformly
departureAt: leg1Result.departureAt, departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt, arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes: totalDurationMinutes:
@@ -339,19 +322,37 @@ export class SearchService {
return results; return results;
} }
// Builds the same result shape as searchSchedules for a single schedule+leg,
// extracted so both direct and transit paths share identical output.
private async buildScheduleResult( private async buildScheduleResult(
schedule: any, schedule: ScheduleWithIncludes,
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
totalPassengers: number, totalPassengers: number,
nationality?: string, nationality?: string,
) { ) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
// Collect all valid seat IDs upfront for a single batch availability check
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
a.coach.seats
.filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim())
.map((s: any) => s.id as string)
);
// Run availability batch and fare calculation in parallel
const [freeSeats, faresByClass] = await Promise.all([
this.segmentsService.getFreeSeatIds(
schedule.id,
allValidSeatIds,
schedule.stopTimes,
originStop.sequence,
destStop.sequence,
),
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
]);
// Compute per-class availability using the pre-computed free seat set
const availabilityByClass: Record<string, number> = {}; const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) { for (const assignment of schedule.coachAssignments) {
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
@@ -362,8 +363,7 @@ export class SearchService {
let count = 0; let count = 0;
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); if (freeSeats.has(seat.id)) count++;
if (free) count++;
} }
if (count > 0) { if (count > 0) {
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
@@ -374,15 +374,13 @@ export class SearchService {
let available = 0; let available = 0;
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); if (freeSeats.has(seat.id)) available++;
if (free) available++;
} }
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
} }
} }
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -400,8 +398,8 @@ export class SearchService {
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
status: schedule.status, status: schedule.status,
stops: schedule.stopTimes stops: schedule.stopTimes
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
availabilityByClass, availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
displayCurrency, displayCurrency,
@@ -500,46 +498,31 @@ export class SearchService {
} }
private async calculateFaresForSegment( private async calculateFaresForSegment(
schedule: any, schedule: ScheduleWithIncludes,
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
nationality?: string, nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> { ): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality); const displayCurrency = resolveCurrencyFromNationality(nationality);
const seatClassIds: string[] = Array.from( // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
new Set( const seatClassMap = new Map<string, any>();
schedule.coachAssignments for (const a of schedule.coachAssignments) {
.flatMap((a: any) => a.coach.coachType?.seatClasses || []) for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
.map((sc: any) => sc.id) if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
.filter((id: any) => id) }
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
} }
const seatClasses = Array.from(seatClassMap.values())
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
const seatClasses = await this.prisma.seatClass.findMany({ if (seatClasses.length === 0) return [];
where: {
isActive: true,
id: { in: seatClassIds }
},
orderBy: { baseFareMinor: 'asc' },
});
if (seatClasses.length === 0) {
console.log(`No active seat classes for schedule ${schedule.id}`);
return [];
}
if (schedule.routeId) { if (schedule.routeId) {
const results = await Promise.all( const results = await Promise.all(
seatClasses.map(async (sc) => { seatClasses.map(async (sc) => {
try { try {
const fare = await this.fareEngine.calculate({ const fare = await this.fareEngine.calculate({
routeId: schedule.routeId, routeId: schedule.routeId!,
originStationId, originStationId,
destinationStationId, destinationStationId,
seatClassId: sc.id, seatClassId: sc.id,
@@ -552,8 +535,7 @@ export class SearchService {
displayCurrency: fare.billingCurrency as Currency, displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
}; };
} catch (error) { } catch {
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
return null; return null;
} }
}), }),
@@ -562,36 +544,32 @@ export class SearchService {
const validResults = results.filter( const validResults = results.filter(
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
); );
if (validResults.length > 0) { if (validResults.length > 0) return validResults;
return validResults;
}
} }
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); // Fallback: use station codes from already-loaded stopTimes when available
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); const originStop = schedule.stopTimes.find(st => st.stationId === originStationId);
const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId);
const originCode = originStop?.station?.code;
const destCode = destStop?.station?.code;
if (originStation && destStation) { if (originCode && destCode) {
const segmentRoute = `${originStation.code}-${destStation.code}`; const segmentRoute = `${originCode}-${destCode}`;
const now = new Date(); const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({ const fareRules = await this.prisma.fareRule.findMany({
where: { where: {
route: segmentRoute, route: segmentRoute,
seatClassId: { in: seatClassIds }, seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
validFrom: { lte: now }, validFrom: { lte: now },
OR: [ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
{ validUntil: null },
{ validUntil: { gte: now } },
],
}, },
}); });
if (fareRules.length > 0) { if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({ return fareRules.map(rule => ({
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
displayCurrency, displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
@@ -599,20 +577,20 @@ export class SearchService {
} }
} }
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return []; return [];
} }
private async buildCoachTypeDetails( // buildCoachTypeDetails is pure in-memory — no async needed
schedule: any, private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
): Promise<Array<{ ): Array<{
coachTypeId: string; coachTypeId: string;
coachTypeName: string; coachTypeName: string;
coachTypeCode: string; coachTypeCode: string;
coachId: string; coachId: string;
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
}>> { }> {
const coachTypeMap = new Map< const coachTypeMap = new Map<
string, string,
{ coachType: any; classNames: Set<string>; coachId: string } { coachType: any; classNames: Set<string>; coachId: string }
@@ -682,14 +660,6 @@ export class SearchService {
return fare.baseFarePerPassengerMinor; return fare.baseFarePerPassengerMinor;
} }
private getDefaultFareForClass(_className: string): never {
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
}
private defaultFare(_seatClassName: string): never {
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule( private selectBestFareRule(
candidates: any[], candidates: any[],
scheduleId: string, scheduleId: string,

View File

@@ -146,6 +146,96 @@ export class SegmentsService {
return true; return true;
} }
/**
* Batch availability check for multiple seats on a single schedule.
* Replaces N×isSeatFreeForLeg calls with 2 queries total.
* Returns a Set of seat IDs that are free for [reqFrom, reqTo).
*/
async getFreeSeatIds(
scheduleId: string,
seatIds: string[],
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
reqFrom: number,
reqTo: number,
): Promise<Set<string>> {
if (seatIds.length === 0) return new Set();
const seqOf = (stationId: string) =>
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
const seatIdSet = new Set(seatIds);
const now = new Date();
const [allHolds, bookedLegs] = await Promise.all([
this.prisma.seatHold.findMany({
where: { scheduleId, expiresAt: { gt: now } },
select: { seatIds: true, createdBy: true },
}),
this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true },
}),
]);
// Determine which seats are blocked by active holds
const holdBlockedSeats = new Set<string>();
for (const hold of allHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy as string);
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
}
} catch { /* ignore */ }
for (const sid of hold.seatIds) {
if (!seatIdSet.has(sid)) continue;
// Conservative block if leg can't be resolved; otherwise check overlap
if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
holdBlockedSeats.add(sid);
}
}
}
// Build full journey ranges per seat (group multi-leg journeys)
const journeyRangesBySeat = new Map<string, Map<string, { from: number; to: number }>>();
for (const leg of bookedLegs) {
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
const depSeq = seqOf(leg.departureStationId);
const arrSeq = seqOf(leg.arrivalStationId);
if (depSeq === undefined || arrSeq === undefined) continue;
let rangeMap = journeyRangesBySeat.get(leg.seatId);
if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); }
const existing = rangeMap.get(leg.journeyId);
rangeMap.set(leg.journeyId, existing
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
: { from: depSeq, to: arrSeq });
}
const freeSeats = new Set<string>();
for (const seatId of seatIds) {
if (holdBlockedSeats.has(seatId)) continue;
let blocked = false;
const rangeMap = journeyRangesBySeat.get(seatId);
if (rangeMap) {
for (const { from, to } of rangeMap.values()) {
if (from < reqTo && reqFrom < to) { blocked = true; break; }
}
}
if (!blocked) freeSeats.add(seatId);
}
return freeSeats;
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
async getOverlappingReservations( async getOverlappingReservations(
scheduleId: string, scheduleId: string,

View File

@@ -259,20 +259,14 @@ export default function SeatsPage() {
})), })),
}); });
const coachesWithSeats = coaches.filter( const coachesWithSeats = coaches.filter((c: any) => {
(c: any) => c.seats && c.seats.length > 0, // Bed coaches store occupants in rooms.beds, not seats
); if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0);
return c.seats && c.seats.length > 0;
if (!currentSchedule?.selectedSeatClass) { });
console.log(
"✅ No filter applied, returning all coaches:",
coachesWithSeats.length,
);
return coachesWithSeats;
}
console.log( console.log(
"✅ No seat class filter - returning all coaches with seats:", "✅ Returning all coaches with seats/beds:",
coachesWithSeats.length, coachesWithSeats.length,
); );
return coachesWithSeats; return coachesWithSeats;
@@ -333,6 +327,8 @@ export default function SeatsPage() {
return seatLabel && !seatLabel.startsWith("-"); return seatLabel && !seatLabel.startsWith("-");
}); });
const isBedCoach = const isBedCoach =
selectedCoachData?.isBedCoach === true ||
seats.some((s: any) => s.bedPosition) ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed"); selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
const allSelected = selectedSeats.length === passengers.length; const allSelected = selectedSeats.length === passengers.length;
const isBedCoach = const isBedCoach =
selectedCoachData?.isBedCoach === true ||
selectedCoachData?.rooms?.length > 0 ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed"); selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
} }
const isBed = const isBed =
coach.isBedCoach === true ||
coach.rooms?.length > 0 ||
coach.seatClass?.toLowerCase().includes("bed") || coach.seatClass?.toLowerCase().includes("bed") ||
coach.mode?.toLowerCase().includes("bed"); coach.mode?.toLowerCase().includes("bed");

View File

@@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "./LanguageSwitcher";
export default function AppHeader() { export default function AppHeader() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -72,9 +71,6 @@ export default function AppHeader() {
<HelpCircle className="w-5 h-5" /> <HelpCircle className="w-5 h-5" />
</Link> </Link>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggler */} {/* Theme Toggler */}
<button <button
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider {
): Promise<ProviderInitiationResult> { ): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken(); const fabricToken = await this.applyFabricToken();
const requestBody = this.buildPreOrderRequest(input); const requestBody = this.buildPreOrderRequest(input);
this.logger.log(
`D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`,
);
const response = await this.postJson<DMoneyPreOrderResponse>( const response = await this.postJson<DMoneyPreOrderResponse>(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
requestBody, requestBody,