import { Injectable, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; export interface Segment { fromStationId: string; toStationId: string; fromSequence: number; toSequence: number; fromName: string; toName: string; } @Injectable() export class SegmentsService { constructor(private prisma: PrismaService) {} async getJourneySegments( scheduleId: string, originStationId: string, destinationStationId: string, ): Promise { const stopTimes = await this.prisma.tripStopTime.findMany({ where: { scheduleId }, include: { station: true }, orderBy: { sequence: 'asc' }, }); const originStop = stopTimes.find(st => st.stationId === originStationId); const destStop = stopTimes.find(st => st.stationId === destinationStationId); if (!originStop || !destStop) { throw new BadRequestException('Origin or destination station not found on this schedule'); } if (originStop.sequence >= destStop.sequence) { throw new BadRequestException('Origin must come before destination'); } const segments: Segment[] = []; for (let i = originStop.sequence; i < destStop.sequence; i++) { const fromStop = stopTimes.find(st => st.sequence === i); const toStop = stopTimes.find(st => st.sequence === i + 1); if (fromStop && toStop) { segments.push({ fromStationId: fromStop.stationId, toStationId: toStop.stationId, fromSequence: fromStop.sequence, toSequence: toStop.sequence, fromName: fromStop.station.name, toName: toStop.station.name, }); } } return segments; } /** * Checks whether a seat is free for the requested leg [reqFrom, reqTo). * * Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo * * This means two journeys that TOUCH at a boundary do NOT conflict: * P1: A(1) → B(2) reqFrom=1, reqTo=2 * P2: B(2) → D(4) reqFrom=2, reqTo=4 * Check P1 vs P2: 1 < 4 AND 2 < 2 → true AND false → NO conflict ✓ * * P3: A(1) → D(4) reqFrom=1, reqTo=4 * Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓ * * Sources checked: * 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId }) * 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys */ 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; } /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, seatId: string, requestedSegments: Segment[], ): Promise<{ type: string; id: string }[]> { const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence)); const reqTo = Math.max(...requestedSegments.map(s => s.toSequence)); const free = await this.isSeatFreeForLeg(scheduleId, seatId, reqFrom, reqTo); return free ? [] : [{ type: 'conflict', id: seatId }]; } segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean { for (const s1 of segments1) { for (const s2 of segments2) { if (s1.fromSequence < s2.toSequence && s2.fromSequence < s1.toSequence) return true; } } return false; } }