diff --git a/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts new file mode 100644 index 000000000..9901eed36 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts @@ -0,0 +1,37 @@ +import { JourneyDirection } from '../../modules/seats/seats.dto'; + +/** + * Shared by SeatsService (seatmap display, hold-creation conflict checks) and + * SegmentsService (search results' availability counts, EnhancedSeatsService) — the + * single source of truth for whether two journey directions on the same schedule + * should be treated as conflicting. Without this, a round-trip's OUTBOUND and RETURN + * legs on the same schedule would wrongly block each other's seats. + * + * Check if two journey directions conflict (should not be allowed simultaneously). + * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on the same schedule. + */ +export function checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean { + // OUTBOUND and RETURN are allowed simultaneously (round-trip on the same schedule) + if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) || + (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) { + return false; + } + + // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings) + if (current === existing) { + return true; + } + + // ONE_WAY conflicts with other ONE_WAY bookings only + if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) { + return true; + } + + // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings) + if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) { + return true; + } + + // Default: no conflict + return false; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index b2bff14b6..708f57792 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -6,6 +6,7 @@ import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @Injectable() export class SeatsService { @@ -137,10 +138,10 @@ export class SeatsService { private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { const n = coachTypeName.toLowerCase(); - // Explicit VIP name check first - if (n.includes('vip')) return 'VIP_BED'; - // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy - if (bedsPerRoom === 4) return 'VIP_BED'; + // Name-based: VIP / Soft Berth Coach → VIP_BED + if (n.includes('vip') || n.includes('soft')) return 'VIP_BED'; + // Beds-per-room fallback: 2 or 4 beds per room = VIP, more = Economy + if (bedsPerRoom != null && bedsPerRoom <= 4) return 'VIP_BED'; return 'ECONOMY_BED'; } @@ -187,6 +188,11 @@ export class SeatsService { return legacyMap[col?.toUpperCase()] ?? null; } + // Delegates the actual "is this seat held/booked for this leg" determination to + // SegmentsService.getSeatAvailabilityMap — the same canonical check search results + // (availabilityByClass) use — so the seatmap and search results can never disagree + // about seat availability again. Previously this method carried its own + // separately-written copy of the same hold/JourneySegment-overlap logic. async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -197,138 +203,42 @@ export class SeatsService { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; - // Resolve the requested leg's sequence range once - let reqFrom: number | undefined; - let reqTo: number | undefined; - let allStopTimes: { stationId: string; sequence: number }[] | null = null; - - const getStopTimes = async () => { - if (!allStopTimes) { - allStopTimes = await this.prisma.tripStopTime.findMany({ - where: { scheduleId }, - select: { stationId: true, sequence: true }, - }); - } - return allStopTimes; - }; + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + // No specific leg requested (or it doesn't resolve to real stops on this + // schedule) — conservatively treat the whole schedule as one big leg, so any + // resolvable hold/booking anywhere on it blocks these seats. Matches this + // method's previous behavior when called without origin/destination. + let reqFrom = -Infinity; + let reqTo = Infinity; if (originStationId && destinationStationId) { - const stops = await getStopTimes(); - const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; - reqFrom = seqOf(originStationId); - reqTo = seqOf(destinationStationId); - } - - // ── Active holds ────────────────────────────────────────────────────────── - const activeHolds = await this.prisma.seatHold.findMany({ - where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } }, - select: { seatIds: true, createdBy: true }, - }); - - const reqDirection = journeyDirection || JourneyDirection.ONE_WAY; - - for (const hold of activeHolds) { - let holdFrom: number | undefined; - let holdTo: number | undefined; - let holdDirection = JourneyDirection.ONE_WAY; - - try { - if (hold.createdBy?.trimStart().startsWith('{')) { - const meta = JSON.parse(hold.createdBy); - const stops = await getStopTimes(); - const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; - holdFrom = seqOf(meta.originStationId); - holdTo = seqOf(meta.destinationStationId); - holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY; - } - } catch { /* ignore */ } - - for (const seatId of hold.seatIds) { - if (!seatIds.includes(seatId)) continue; - - // Check leg overlap - const legsOverlap = - reqFrom === undefined || reqTo === undefined || - holdFrom === undefined || holdTo === undefined || - (holdFrom < reqTo && reqFrom < holdTo); - - // Check direction conflict - const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection); - - if (!legsOverlap || !directionsConflict) { - // This hold does not conflict with the requested leg/direction. - // Explicitly mark AVAILABLE so the DB's HELD status (set by the - // opposing-direction hold) does not bleed through via the fallback. - if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE'); - continue; - } - - statusMap.set(seatId, 'HELD'); + const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence; + const resolvedFrom = seqOf(originStationId); + const resolvedTo = seqOf(destinationStationId); + if (resolvedFrom !== undefined && resolvedTo !== undefined) { + reqFrom = resolvedFrom; + reqTo = resolvedTo; } } - // ── Confirmed bookings via JourneySegment ───────────────────────────────── - const bookedSegments = await this.prisma.journeySegment.findMany({ - where: { - scheduleId, - seatId: { in: seatIds }, - journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, - }, - select: { seatId: true, departureStationId: true, arrivalStationId: true }, - }); + const availability = await this.segmentsService.getSeatAvailabilityMap( + scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, + ); - if (reqFrom !== undefined && reqTo !== undefined) { - const stops = await getStopTimes(); - const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; - for (const seg of bookedSegments) { - if (!seg.seatId) continue; - const segFrom = seqOf(seg.departureStationId); - const segTo = seqOf(seg.arrivalStationId); - if (segFrom !== undefined && segTo !== undefined) { - if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED'); - } else { - statusMap.set(seg.seatId, 'BOOKED'); - } - } - } else { - for (const seg of bookedSegments) { - if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); - } + // Every requested seat defaults to AVAILABLE — this also guards against a stale + // persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding + // through getSeatMap's own fallback, since that fallback only triggers when this + // map has no entry at all for a given seat. + for (const seatId of seatIds) { + statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); } return statusMap; } - /** - * Check if two journey directions conflict (should not be allowed simultaneously) - * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule - */ - private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean { - // OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules) - if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) || - (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) { - return false; - } - - // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings) - if (current === existing) { - return true; - } - - // ONE_WAY conflicts with other ONE_WAY bookings only - if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) { - return true; - } - - // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings) - if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) { - return true; - } - - // Default: no conflict - return false; - } - async holdSeats(dto: HoldSeatsDto) { const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); @@ -432,7 +342,7 @@ export class SeatsService { const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo); if (!legsOverlap) continue; - const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection); + const directionsConflict = checkDirectionConflict(currentDirection, holdDirection); if (!directionsConflict) continue; for (const { passengerId, seatId } of dto.passengers) { diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 16b486bbe..aaeb51e03 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -1,5 +1,7 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { JourneyDirection } from '../seats/seats.dto'; +import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; export interface Segment { fromStationId: string; @@ -54,7 +56,12 @@ export class SegmentsService { } /** - * Checks whether a seat is free for the requested leg [reqFrom, reqTo). + * Canonical per-seat availability check for a leg [reqFrom, reqTo) — the single + * source of truth used by search results (availabilityByClass), the interactive + * seatmap (SeatsService.resolveEffectiveStatuses), and hold-conflict checking, so + * they can never disagree about whether a given seat is free. Previously + * SeatsService maintained its own separately-written copy of this same + * hold/booking-overlap logic, which could (and did) drift out of sync with this one. * * Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo * @@ -66,99 +73,30 @@ export class SegmentsService { * P3: A(1) → D(4) reqFrom=1, reqTo=4 * Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓ * + * journeyDirection lets a round-trip's OUTBOUND and RETURN holds coexist on the + * same schedule without blocking each other (see checkDirectionConflict) — omit it + * for one-way contexts, where it defaults to ONE_WAY (conflicts with anything). + * * Sources checked: - * 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId }) + * 1. Active SeatHolds — leg + direction decoded from createdBy JSON + * ({ originStationId, destinationStationId, journeyDirection }) * 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys + * (JourneySegment carries no direction — a confirmed booking always blocks, + * regardless of the requester's own direction) + * + * Returns a map from seatId to 'HELD' | 'BOOKED' — seats with no entry are free. + * BOOKED takes priority when a seat is somehow reported as both. */ - async isSeatFreeForLeg( - scheduleId: string, - seatId: string, - reqFrom: number, - reqTo: number, - ): Promise { - // ── Load stop-time sequences once ──────────────────────────────────────── - const stopTimes = await this.prisma.tripStopTime.findMany({ - where: { scheduleId }, - select: { stationId: true, sequence: true }, - }); - const seqOf = (stationId: string) => - stopTimes.find(s => s.stationId === stationId)?.sequence; - - // ── 1. Active holds ─────────────────────────────────────────────────────── - const activeHolds = await this.prisma.seatHold.findMany({ - where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } }, - }); - - for (const hold of activeHolds) { - // Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers } - let holdFrom: number | undefined; - let holdTo: number | undefined; - try { - if (hold.createdBy) { - const meta = JSON.parse(hold.createdBy); - holdFrom = seqOf(meta.originStationId); - holdTo = seqOf(meta.destinationStationId); - } - } catch { /* ignore */ } - - if (holdFrom !== undefined && holdTo !== undefined) { - if (holdFrom < reqTo && reqFrom < holdTo) return false; - } else { - // Cannot resolve leg — conservative block - return false; - } - } - - // ── 2. Active JourneySegments ───────────────────────────────────────────── - // Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the - // full range [min(depSeq), max(arrSeq)] per journey for this seat. - const bookedLegs = await this.prisma.journeySegment.findMany({ - where: { - scheduleId, - seatId, - journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, - }, - }); - - // Group legs by journeyId → find the full range each journey occupies - const journeyRanges = new Map(); - for (const leg of bookedLegs) { - const depSeq = seqOf(leg.departureStationId); - const arrSeq = seqOf(leg.arrivalStationId); - if (depSeq === undefined || arrSeq === undefined) continue; - - const existing = journeyRanges.get(leg.journeyId); - if (!existing) { - journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq }); - } else { - journeyRanges.set(leg.journeyId, { - from: Math.min(existing.from, depSeq), - to: Math.max(existing.to, arrSeq), - }); - } - } - - for (const { from, to } of journeyRanges.values()) { - // Strict overlap: existingFrom < reqTo AND reqFrom < existingTo - if (from < reqTo && reqFrom < to) return false; - } - - 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( + async getSeatAvailabilityMap( scheduleId: string, seatIds: string[], stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, reqFrom: number, reqTo: number, - ): Promise> { - if (seatIds.length === 0) return new Set(); + journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY, + ): Promise> { + const result = new Map(); + if (seatIds.length === 0) return result; const seqOf = (stationId: string) => stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; @@ -181,29 +119,31 @@ export class SegmentsService { }), ]); - // Determine which seats are blocked by active holds - const holdBlockedSeats = new Set(); + // ── 1. Active holds ──────────────────────────────────────────────────────── for (const hold of allHolds) { let holdFrom: number | undefined; let holdTo: number | undefined; + let holdDirection = JourneyDirection.ONE_WAY; try { if (hold.createdBy) { const meta = JSON.parse(hold.createdBy as string); holdFrom = seqOf(meta.originStationId); holdTo = seqOf(meta.destinationStationId); + holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY; } } 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); - } + // Conservative block if leg can't be resolved; otherwise check overlap. + const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo); + if (!legsOverlap) continue; + if (!checkDirectionConflict(journeyDirection, holdDirection)) continue; + result.set(sid, 'HELD'); } } - // Build full journey ranges per seat (group multi-leg journeys) + // ── 2. Active JourneySegments — per-seat, per-journey leg ranges ────────── const journeyRangesBySeat = new Map>(); for (const leg of bookedLegs) { if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; @@ -220,20 +160,52 @@ export class SegmentsService { : { from: depSeq, to: arrSeq }); } - const freeSeats = new Set(); 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 (!rangeMap) continue; + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { result.set(seatId, 'BOOKED'); break; } } - if (!blocked) freeSeats.add(seatId); } - return freeSeats; + return result; + } + + /** + * Batch availability check for multiple seats on a single schedule. + * Thin wrapper around getSeatAvailabilityMap — returns just the free-seat set. + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY, + ): Promise> { + if (seatIds.length === 0) return new Set(); + const statusMap = await this.getSeatAvailabilityMap( + scheduleId, seatIds, stopTimesForSeqLookup, reqFrom, reqTo, journeyDirection, + ); + return new Set(seatIds.filter(id => !statusMap.has(id))); + } + + /** + * Single-seat convenience wrapper around getSeatAvailabilityMap. + */ + async isSeatFreeForLeg( + scheduleId: string, + seatId: string, + reqFrom: number, + reqTo: number, + journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY, + ): Promise { + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + const freeSeats = await this.getFreeSeatIds(scheduleId, [seatId], stopTimes, reqFrom, reqTo, journeyDirection); + return freeSeats.has(seatId); } /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */