Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-17 23:20:10 +03:00
195 changed files with 8154 additions and 1223 deletions

View File

@@ -12,7 +12,7 @@ export class LiveService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const live = schedule.liveStatus;
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
return {
scheduleId: schedule.id, trainName: schedule.train.name,
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,

View File

@@ -6,6 +6,7 @@ export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
}
export class CreateRouteDto {
@@ -35,6 +36,7 @@ export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
}
export class UpdateRouteDto {
@@ -42,6 +44,7 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -36,6 +36,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
},
},
@@ -92,6 +93,7 @@ export class RoutesService {
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -103,6 +105,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
});
}
@@ -221,6 +224,7 @@ export class RoutesService {
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
},
});
}

View File

@@ -12,10 +12,10 @@ export enum TripStatus {
}
export enum StopStatus {
OPEN = 'OPEN',
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
BOARDED = 'BOARDED',
COMPLETED = 'COMPLETED',
APPROACHING = 'APPROACHING',
CURRENT = 'CURRENT',
UPCOMING = 'UPCOMING',
}
export enum PassengerCategory {
@@ -70,7 +70,7 @@ export class UpdateScheduleDto {
export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
}
export class CreateFareRuleDto {

View File

@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

View File

@@ -6,7 +6,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -20,6 +19,7 @@ type ScheduleWithIncludes = {
train: any;
originStation: any;
destinationStation: any;
route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null;
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 } }>;
};
@@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
@@ -41,13 +42,8 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -218,10 +214,11 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
// Use now as the lower bound for today so we don't fetch schedules that have
// already fully departed. The per-segment cutoff check in buildScheduleResult
// handles the exact check using each stop's own plannedDepartureAt.
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
const earliest = isToday ? cutoffThreshold : date;
const earliest = isToday ? now : date;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
@@ -284,12 +281,9 @@ export class SearchService {
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -376,6 +370,16 @@ export class SearchService {
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
// Segment-level cutoff: use the origin stop's planned departure, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt = 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;
// Collect all valid seat IDs upfront for a single batch availability check
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
a.coach.seats

View File

@@ -268,23 +268,38 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const [holdMinutes, cutoffHours] = await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
]);
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { departureAt: true },
});
const [schedule, originStopTime, originRouteStop] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: {
departureAt: true,
route: { select: { checkinMinutesBefore: true } },
},
}),
this.prisma.tripStopTime.findFirst({
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
select: { plannedDepartureAt: true },
}),
this.prisma.routeStop.findFirst({
where: {
route: { schedules: { some: { id: dto.scheduleId } } },
stationId: dto.originStationId,
},
select: { checkinMinutesBefore: true },
}),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
const cutoffMs = cutoffHours * 60 * 60 * 1000;
if (msUntilDeparture <= cutoffMs) {
// Stop-level override wins; falls back to route-level; then to 30 min.
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
throw new BadRequestException(
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
);
}

View File

@@ -42,6 +42,7 @@ export class TasksService {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// ── Schedule-level transitions (operational display) ───────────────────
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
@@ -57,9 +58,71 @@ export class TasksService {
}),
]);
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
// ── Per-stop transitions (segment-level status) ────────────────────────
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
// override; falls back to the Route-level value when null.
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
const routeStops = await this.prisma.routeStop.findMany({
select: {
routeId: true,
stationId: true,
checkinMinutesBefore: true,
route: { select: { checkinMinutesBefore: true } },
},
});
// Map: effectiveMins → Map<routeId, stationId[]>
const byMins = new Map<number, Map<string, string[]>>();
for (const stop of routeStops) {
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
if (!byMins.has(mins)) byMins.set(mins, new Map());
const byRoute = byMins.get(mins)!;
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
byRoute.get(stop.routeId)!.push(stop.stationId);
}
let reopenedCount = 0;
let checkinClosedCount = 0;
for (const [mins, byRoute] of byMins) {
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
for (const [routeId, stationIds] of byRoute) {
// Revert first: if the cutoff was reduced, stops that were prematurely closed
// should reopen (departure is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'OPEN' },
});
reopenedCount += reverted.count;
// Forward: close stops now within the cutoff window.
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'CHECKIN_CLOSED' },
});
checkinClosedCount += closed.count;
}
}
const boardedStops = await this.prisma.tripStopTime.updateMany({
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
data: { status: 'BOARDED' },
});
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
this.logger.log(
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
);
}
}
@@ -96,13 +159,17 @@ export class TasksService {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
// passenger's segment may depart well after the schedule's first stop, and
// that first-stop time could already be in the past even though B→C is still open.
},
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
},
@@ -110,9 +177,15 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -176,6 +249,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
paymentIntent: { select: { method: true } },
@@ -187,9 +262,14 @@ export class TasksService {
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
// Use the booking's origin-segment departure for the deadline so that a B→C booking
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
const createdAt = booking.createdAt as Date;
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
@@ -201,7 +281,7 @@ export class TasksService {
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
// though the booking is now cancelled. Scoped to this booking's own schedule,
// since the same physical Seat row is reused across other recurring dates.
const seatIds = booking.seats.map(s => s.seatId);
const seatIds = booking.seats.map((s: any) => s.seatId);
if (seatIds.length > 0) {
await this.prisma.seatHold.deleteMany({
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },