From 8bba882710a873e32bfb263fdd74f34585c40027 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Thu, 23 Jul 2026 20:03:35 +0300 Subject: [PATCH] Adding stop based booking configuration --- .../migration.sql | 9 + .../test/checkin-cutoff.e2e-spec.ts | 203 ++++++++++++++++++ .../backoffice/src/lib/timezone.ts | 21 ++ .../specs/backoffice/checkin-cutoff.spec.ts | 100 +++++++++ 4 files changed, 333 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql create mode 100644 apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/timezone.ts create mode 100644 e2e-ui/specs/backoffice/checkin-cutoff.spec.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql new file mode 100644 index 000000000..59ae79426 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql @@ -0,0 +1,9 @@ +-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the +-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting +-- distance-proportional interpolation). Nullable — falls back to distance interpolation +-- when unset. +-- Uses IF NOT EXISTS following the pattern established in +-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations +-- checked in as empty "applied directly" placeholders that never reached the deployed DB. + +ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER; diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts new file mode 100644 index 000000000..8cbc7d8f8 --- /dev/null +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -0,0 +1,203 @@ +/** + * Per-station check-in cutoff — proves booking closure is now based on each stop's own + * ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's + * overall departure. The regression this guards: before this change, all stops effectively + * shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one + * wrongly left open) together with the rest of the route. + * + * Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises + * the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService + * and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule + * → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're + * instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in + * money-integrity.e2e-spec.ts. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TasksService } from "../src/modules/tasks/tasks.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */ +async function createTestSchedule( + harness: ServiceHarness, + schedules: SchedulesService, + opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }, +) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const schedule = await schedules.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + } as any); + + // Give the schedule a bookable coach so holdSeats has real seats to work with. + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + await harness.prisma.coachAssignment.create({ + data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + + return { schedule, seats }; +} + +describe("Check-in cutoff — arrival-time basis, per-station independence", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let tasksService: TasksService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const systemConfig = new SystemConfigService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); + }); + + afterAll(async () => { + await harness?.close(); + }); + + it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => { + await resetAndSeedCore(harness.prisma); + + // dep only 5 min out (createSchedule requires a future departureAt). Route-level default + // checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is + // already ~25 min in the past by the time this runs — but B, with a 60-min travel time from + // A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is + // still ~35 min in the future. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-A-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within/i); + + const held = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }], + } as any); + expect(held).toBeTruthy(); + }); + + it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => { + // Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact + // schedule's B segment would still be OPEN (see previous test), so a rejection here proves + // the stop-level override, not the default, is what's actually being applied. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-B-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within 90 minute/i); + }); + + it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-C-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await tasksService.syncScheduleStatuses(); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status])); + expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED"); + expect(byStation[IDS.stationB]).toBe("OPEN"); + expect(byStation[IDS.stationC]).toBe("OPEN"); + }); + + it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => { + await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop + + const dep = new Date(Date.now() + 60 * 60_000); + const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-D-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const totalDuration = arr.getTime() - dep.getTime(); + const bProgress = DISTANCE.B / DISTANCE.C; + const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress); + + const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!; + expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime()); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/lib/timezone.ts b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts new file mode 100644 index 000000000..5c6097feb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts @@ -0,0 +1,21 @@ +/** + * The backend always interprets bare datetime strings (no timezone suffix) as East African + * Time (EAT, UTC+3) and stores everything as UTC — see parseEthiopianTime() in + * apps/edr-passenger-api/src/common/utils/timezone.utils.ts. These mirror that on the frontend + * so `` fields round-trip correctly regardless of the browser's + * own local timezone. + */ + +/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return a UTC ISO string. */ +export function eatLocalToISO(local: string): string { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); +} + +/** Convert a UTC ISO string/Date to a datetime-local value ("YYYY-MM-DDTHH:mm") in EAT (UTC+3). */ +export function isoToEATLocal(iso: string | Date): string { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); +} diff --git a/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts new file mode 100644 index 000000000..b123f24bc --- /dev/null +++ b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Track B — per-station check-in cutoff, driven by the backoffice route config API. Follows the + * same convention as config-validation.spec.ts: hits the passenger-api directly with a staff + * bearer token rather than driving the real DOM — the route-stop form has no data-testid hooks, + * so browser automation here would be selector-fragile for no extra coverage value. + * + * Creates its OWN route (not the shared ROUTE_ID from seed-ui) since updateRoute deletes and + * recreates all stops — mutating the shared fixture route would break every other spec that + * depends on its distances/times staying stable for the whole suite run. + */ +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival independently of route-wide departure", async ({ request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-CUTOFF-${Date.now()}`, + name: "E2E Check-in Cutoff Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1 }, + { stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 }, + { stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data ?? (await routeRes.json()); + const routeId = route.id; + + // Reuse the seeded coach via a route coach template so schedule creation auto-assigns real + // seats (createSchedule auto-applies any route coach template — see schedules.service.ts). + const templateRes = await request.put(`${API_URL}/routes/${routeId}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + // dep only 5 min out: A's cutoff (dep - 30min default) is already ~25 min in the past by the + // time this schedule is queried, but B's arrival (dep + 60min) keeps its own cutoff (arrival - + // 30min default) about 35 min in the future — proving the two stations close independently. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data ?? (await scheduleRes.json()); + + const getRes = await request.get(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }); + expect(getRes.ok()).toBeTruthy(); + const full = (await getRes.json())?.data ?? (await getRes.json()); + const stopTimes: any[] = full.stopTimes ?? []; + const bStop = stopTimes.find((s) => s.stationId === STATIONS.B); + const cStop = stopTimes.find((s) => s.stationId === STATIONS.C); + + expect(new Date(bStop.plannedArrivalAt).getTime()).toBe(dep.getTime() + 60 * 60_000); + expect(new Date(cStop.plannedArrivalAt).getTime()).toBe(arr.getTime()); // last stop locked to overall arrival + + // A's own segment (no arrival — falls back to its departure) is already past its cutoff... + const rejectRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.A, + destinationStationId: STATIONS.B, + passengers: [{ passengerId: "44444444-4444-4444-8444-444444444444", seatId: "00000000-0000-4000-8000-000000009999" }], + }, + }); + expect(rejectRes.status()).toBe(400); + expect((await rejectRes.json())?.message ?? "").toMatch(/cannot be held within/i); + + // ...while B, whose own arrival is comfortably later, remains independently bookable. + const seatmapRes = await request.get(`${API_URL}/seats/seatmap/${schedule.id}`, { headers: auth() }); + expect(seatmapRes.ok()).toBeTruthy(); + const seatmap = (await seatmapRes.json())?.data ?? (await seatmapRes.json()); + const seatId = seatmap.coaches?.[0]?.seats?.[0]?.id; + expect(seatId).toBeTruthy(); + + const holdRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.B, + destinationStationId: STATIONS.C, + passengers: [{ passengerId: "55555555-5555-4555-8555-555555555555", seatId }], + }, + }); + expect(holdRes.ok()).toBeTruthy(); + + await request.delete(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }).catch(() => {}); + await request.delete(`${API_URL}/routes/${routeId}?cascade=true`, { headers: auth() }).catch(() => {}); +});