Added Checkin duration configuration

This commit is contained in:
Roba Boru
2026-07-17 22:12:54 +03:00
parent 0c584cce88
commit 738b7df5cf
14 changed files with 297 additions and 132 deletions

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 } },