From 2ceb993c60ac1a2c0a772d6c38bb9608c2802657 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 27 May 2026 15:11:23 +0300 Subject: [PATCH] Segment based seat assignement added --- .../modules/schedules/schedules.service.ts | 58 ++- .../src/modules/search/search.module.ts | 3 +- .../src/modules/search/search.service.ts | 69 +--- .../src/modules/seats/seats.controller.ts | 28 ++ .../src/modules/seats/seats.dto.ts | 38 +- .../src/modules/seats/seats.module.ts | 8 +- .../src/modules/seats/seats.service.ts | 334 +++++++++++++++++- .../segments/enhanced-seats.service.ts | 70 ++-- .../modules/segments/segments.controller.ts | 127 ++----- .../src/modules/segments/segments.service.ts | 185 ++++++---- 10 files changed, 630 insertions(+), 290 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index eb10d6e0b..36e8a33ed 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -102,7 +102,63 @@ export class SchedulesService { }, }); if (!schedule) throw new NotFoundException('Schedule not found'); - return schedule; + + // Compute effective seat statuses from SeatHold + JourneySegment + // (seat.status DB column is no longer written during booking) + const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds); + + return { + ...schedule, + coachAssignments: schedule.coachAssignments.map(a => ({ + ...a, + coach: { + ...a.coach, + seats: a.coach.seats.map(s => ({ + ...s, + status: effectiveStatuses.get(s.id) ?? s.status, + })), + }, + })), + }; + } + + /** + * Computes effective seat status for a schedule by checking active SeatHolds + * and confirmed JourneySegments. The DB seat.status column is not written + * during segment-based booking, so this overlay is required. + * Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE + */ + private async resolveEffectiveStatuses( + scheduleId: string, + seatIds: string[], + ): Promise> { + const statusMap = new Map(); + if (seatIds.length === 0) return statusMap; + + const [activeHolds, bookedSegments] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } }, + select: { seatIds: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }), + ]); + + for (const hold of activeHolds) + for (const seatId of hold.seatIds) + if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD'); + + for (const seg of bookedSegments) + if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + + return statusMap; } updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) { diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts index fbf907fdd..baadcf90c 100644 --- a/apps/edr-passenger-api/src/modules/search/search.module.ts +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -3,9 +3,10 @@ import { SearchController } from './search.controller'; 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'; @Module({ - imports: [CurrencyModule, FareEngineModule], + imports: [CurrencyModule, FareEngineModule, SegmentsModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e5e585bd0..c534586dd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; +import { SegmentsService } from '../segments/segments.service'; import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @@ -13,6 +14,7 @@ export class SearchService { private prisma: PrismaService, private currencyService: CurrencyService, private fareEngine: FareEngineService, + private segmentsService: SegmentsService, ) {} async searchTrips(dto: SearchTripsDto) { @@ -58,7 +60,8 @@ export class SearchService { for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED') continue; - const free = await this.isSeatFreeForSegment( + // Use segment-aware check — a seat booked A→B is still free for B→D + const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); @@ -237,70 +240,6 @@ export class SearchService { }; } - /** - * Returns true if the seat has no active hold or confirmed booking - * whose segment range overlaps [fromSeq, toSeq). - * Overlap condition: existingFrom < toSeq AND fromSeq < existingTo - */ - private async isSeatFreeForSegment( - scheduleId: string, - seatId: string, - fromSeq: number, - toSeq: number, - ): Promise { - // Check active holds that include this seat on this schedule - const holds = await this.prisma.seatHold.findMany({ - where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } }, - }); - - for (const hold of holds) { - // Resolve hold segment range from its stored origin/destination via JourneySegment - // For holds we use the stop sequences stored on the hold's origin/destination - // Since SeatHold doesn't store sequences directly, we check JourneySegments - // that reference this seat on this schedule with PENDING_PAYMENT status - const holdSegs = await this.prisma.journeySegment.findMany({ - where: { scheduleId, seatId }, - include: { - journey: true, - schedule: { include: { stopTimes: true } }, - }, - }); - - for (const js of holdSegs) { - const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence; - const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence; - if (depSeq !== undefined && arrSeq !== undefined) { - if (depSeq < toSeq && fromSeq < arrSeq) return false; - } - } - - // If no journey segments yet (hold just created), treat the whole hold as blocking - if (holdSegs.length === 0) return false; - } - - // Check confirmed/pending bookings via JourneySegment - const bookedSegments = await this.prisma.journeySegment.findMany({ - where: { - scheduleId, - seatId, - journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, - }, - include: { - schedule: { include: { stopTimes: true } }, - }, - }); - - for (const js of bookedSegments) { - const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence; - const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence; - if (depSeq !== undefined && arrSeq !== undefined) { - if (depSeq < toSeq && fromSeq < arrSeq) return false; - } - } - - return true; - } - private defaultFare(seatClassName: string): number { const fares: Record = { 'Economy Regular': 45000, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 4c93b5f61..a555f2513 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -26,6 +26,34 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); } // ── Hold / Release ──────────────────────────────────────────────────────── + @Get('holds') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'List active seat holds with full leg context', + description: `Returns all non-expired holds enriched with: +- **schedule**: train number, departure/arrival, full route origin→destination +- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence) +- **seats**: seat label, coach, seat class, row, col +- **ttlSeconds**: seconds remaining before the hold expires + +This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`, + }) + @ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' }) + @ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' }) + @ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' }) + getHolds( + @Query('scheduleId') scheduleId?: string, + @Query('passengerId') passengerId?: string, + ) { return this.service.getHolds(scheduleId, passengerId); } + + @Get('holds/:holdId') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get a single hold with full leg context' }) + @ApiParam({ name: 'holdId', description: 'SeatHold UUID' }) + @ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' }) + @ApiResponse({ status: 404, description: 'Hold not found' }) + getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); } + @Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 6af6fe203..4818fe59f 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -1,9 +1,35 @@ -import { IsString, IsArray, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsArray, ValidateNested } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class PassengerSeatDto { + @ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' }) + @IsString() passengerId: string; + + @ApiProperty({ example: 'seat-uuid', description: 'Seat UUID assigned to this passenger' }) + @IsString() seatId: string; +} export class HoldSeatsDto { - @ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string; - @ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string; - @ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[]; - @ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string; + @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — scopes the hold to a leg so the seat can be reused on non-overlapping legs' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' }) + @IsString() destinationStationId: string; + + @ApiProperty({ + type: [PassengerSeatDto], + description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.', + example: [ + { passengerId: 'passenger-uuid-1', seatId: 'seat-uuid-1' }, + { passengerId: 'passenger-uuid-2', seatId: 'seat-uuid-2' }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PassengerSeatDto) + passengers: PassengerSeatDto[]; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index c9394983d..014e0918a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; import { SeatsController } from './seats.controller'; import { SeatsService } from './seats.service'; +import { SegmentsModule } from '../segments/segments.module'; -@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] }) +@Module({ + imports: [SegmentsModule], + controllers: [SeatsController], + providers: [SeatsService], + exports: [SeatsService], +}) export class SeatsModule {} 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 606cb4902..96cf4e1bf 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1,11 +1,15 @@ -import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { HoldSeatsDto } from './seats.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; +import { SegmentsService } from '../segments/segments.service'; @Injectable() export class SeatsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private segmentsService: SegmentsService, + ) {} // ── Seat Map ────────────────────────────────────────────────────────────── async getSeatMap(scheduleId: string, coachId?: string) { @@ -14,6 +18,10 @@ export class SeatsService { include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } }, orderBy: { positionNumber: 'asc' }, }); + + const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds); + return { coaches: assignments.map((a) => ({ id: a.coach.id, @@ -21,34 +29,334 @@ export class SeatsService { name: `Coach ${a.coach.label}`, seatClass: a.coach.seatClass.name, positionNumber: a.positionNumber, - seats: a.coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })), + seats: a.coach.seats.map((s) => ({ + id: s.id, + number: s.label, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + bedPosition: s.bedPosition, + })), })), }; } + /** + * Computes the effective seat status for a set of seats on a specific schedule + * by checking active SeatHolds and confirmed JourneySegments. + * + * Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE + * + * This is needed because seat.status is no longer written during booking — + * availability is segment-scoped, so the DB column stays AVAILABLE even when held. + */ + async resolveEffectiveStatuses( + scheduleId: string, + seatIds: string[], + ): Promise> { + const statusMap = new Map(); + + if (seatIds.length === 0) return statusMap; + + // 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD + const activeHolds = await this.prisma.seatHold.findMany({ + where: { + scheduleId, + expiresAt: { gt: new Date() }, + seatIds: { hasSome: seatIds }, + }, + select: { seatIds: true }, + }); + for (const hold of activeHolds) { + for (const seatId of hold.seatIds) { + if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD'); + } + } + + // 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED + // (overwrites HELD if the same seat has a confirmed booking) + const bookedSegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + for (const seg of bookedSegments) { + if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + } + + return statusMap; + } + // ── Hold / Release ──────────────────────────────────────────────────────── async holdSeats(dto: HoldSeatsDto) { + // ── Validate request integrity ─────────────────────────────────────────── + const passengerIds = dto.passengers.map(p => p.passengerId); + const seatIds = dto.passengers.map(p => p.seatId); + + if (new Set(passengerIds).size !== passengerIds.length) + throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once'); + if (new Set(seatIds).size !== seatIds.length) + throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + const hold = await this.prisma.$transaction(async (tx) => { - const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } }); - const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date())); - if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable'); - await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); - return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } }); + // ── 1. Validate seats exist and none are BLOCKED ───────────────────── + const seats = await tx.seat.findMany({ + where: { id: { in: seatIds } }, + select: { id: true, status: true, label: true }, + }); + + if (seats.length !== seatIds.length) { + const found = new Set(seats.map(s => s.id)); + const missing = seatIds.filter(id => !found.has(id)); + throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); + } + + const blocked = seats.filter(s => s.status === 'BLOCKED'); + if (blocked.length > 0) + throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`); + + const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label])); + + // ── 2. Resolve requested leg sequences ────────────────────────────── + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId: dto.scheduleId }, + select: { stationId: true, sequence: true }, + }); + const seqOf = (stationId: string) => + stopTimes.find(s => s.stationId === stationId)?.sequence; + + const reqFrom = seqOf(dto.originStationId); + const reqTo = seqOf(dto.destinationStationId); + + if (reqFrom === undefined || reqTo === undefined) + throw new BadRequestException('Origin or destination station not found on this schedule'); + if (reqFrom >= reqTo) + throw new BadRequestException('Origin must come before destination'); + + // ── 3. Load active holds for this schedule ─────────────────────────── + const activeHolds = await tx.seatHold.findMany({ + where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, + select: { seatIds: true, createdBy: true }, + }); + + // Parse each hold's leg range and passenger list + const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; + for (const h of activeHolds) { + try { + if (h.createdBy?.trimStart().startsWith('{')) { + const meta = JSON.parse(h.createdBy); + const holdFrom = seqOf(meta.originStationId); + const holdTo = seqOf(meta.destinationStationId); + if (holdFrom !== undefined && holdTo !== undefined) { + parsedHolds.push({ + seatIds: h.seatIds, + from: holdFrom, + to: holdTo, + passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), + }); + } + } + } catch { /* ignore malformed */ } + } + + // ── 4. Per-passenger validation with overlap check ─────────────────── + for (const { passengerId, seatId } of dto.passengers) { + for (const hold of parsedHolds) { + const legsOverlap = hold.from < reqTo && reqFrom < hold.to; + if (!legsOverlap) continue; // non-overlapping leg — no conflict + + // Rule A: seat is held on an overlapping leg + if (hold.seatIds.includes(seatId)) { + throw new ConflictException( + `Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`, + ); + } + + // Rule B: passenger already holds a seat on an overlapping leg + if (hold.passengerIds.includes(passengerId)) { + throw new ConflictException( + `Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`, + ); + } + } + } + + // Store passenger→seat mapping AND leg in createdBy as JSON + const holdMeta = { + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), + }; + + return tx.seatHold.create({ + data: { + scheduleId: dto.scheduleId, + passengerId: dto.passengers[0].passengerId, + seatIds, + createdBy: JSON.stringify(holdMeta), + expiresAt, + }, + }); }); - return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt }; + + return this.enrichHold(hold); + } + + async getHolds(scheduleId?: string, passengerId?: string) { + const holds = await this.prisma.seatHold.findMany({ + where: { + expiresAt: { gt: new Date() }, + ...(scheduleId ? { scheduleId } : {}), + ...(passengerId ? { passengerId } : {}), + }, + orderBy: { createdAt: 'desc' }, + }); + return Promise.all(holds.map(h => this.enrichHold(h))); + } + + async getHold(holdId: string) { + const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); + if (!hold) throw new NotFoundException('Hold not found'); + return this.enrichHold(hold); + } + + /** + * Resolves the opaque fareQuoteId leg encoding into human-readable station + * names and enriches the hold with schedule, seat, and leg details. + */ + private async enrichHold(hold: any) { + // Decode leg and passenger→seat mapping from createdBy JSON + let originStationId: string | null = null; + let destinationStationId: string | null = null; + let passengerSeatMap: { passengerId: string; seatId: string }[] = []; + + try { + if (hold.createdBy) { + const raw = hold.createdBy; + // Guard: only parse if it looks like a JSON object, not a plain number/string + if (typeof raw === 'string' && raw.trimStart().startsWith('{')) { + const meta = JSON.parse(raw); + originStationId = meta.originStationId ?? null; + destinationStationId = meta.destinationStationId ?? null; + passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : []; + } + } + } catch { /* ignore malformed createdBy */ } + + const seatIds = hold.seatIds as string[]; + + const [schedule, originStation, destinationStation, seats] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: hold.scheduleId }, + include: { train: true, originStation: true, destinationStation: true }, + }), + originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null, + destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null, + this.prisma.seat.findMany({ + where: { id: { in: seatIds } }, + include: { coach: { include: { seatClass: true } } }, + }), + ]); + + let originSequence: number | null = null; + let destinationSequence: number | null = null; + if (originStationId && destinationStationId) { + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } }, + select: { stationId: true, sequence: true }, + }); + originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null; + destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null; + } + + // Build seat map keyed by seatId for quick lookup + const seatById = Object.fromEntries(seats.map(s => [s.id, s])); + + // Merge passenger→seat mapping with seat details + const passengers = passengerSeatMap.length > 0 + ? passengerSeatMap.map(({ passengerId, seatId }) => { + const s = seatById[seatId]; + return { + passengerId, + seat: s ? { + id: s.id, + label: s.label, + seatNumber: s.seatNumber, + coach: s.coach.label, + seatClass: s.coach.seatClass.name, + row: s.row, + col: s.col, + } : { id: seatId }, + }; + }) + // Fallback for holds created before this change + : seatIds.map(seatId => { + const s = seatById[seatId]; + return { + passengerId: hold.passengerId, + seat: s ? { + id: s.id, + label: s.label, + seatNumber: s.seatNumber, + coach: s.coach.label, + seatClass: s.coach.seatClass.name, + row: s.row, + col: s.col, + } : { id: seatId }, + }; + }); + + return { + holdId: hold.id, + expiresAt: hold.expiresAt, + createdAt: hold.createdAt, + ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)), + schedule: schedule ? { + id: schedule.id, + trainNumber: schedule.train.number, + trainName: schedule.train.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + fullRouteOrigin: schedule.originStation.name, + fullRouteDestination: schedule.destinationStation.name, + } : null, + leg: { + originStationId, + originStationName: originStation?.name ?? null, + originStationCode: originStation?.code ?? null, + originSequence, + destinationStationId, + destinationStationName: destinationStation?.name ?? null, + destinationStationCode: destinationStation?.code ?? null, + destinationSequence, + }, + passengers, + }; } async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } }); await this.prisma.seatHold.delete({ where: { id: holdId } }); - return { released: true }; + return { released: true, holdId }; } - async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); } - async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); } + async confirmSeats(seatIds: string[]) { + // No-op for status — availability is segment-scoped via JourneySegment + // seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag + } + async releaseSeats(seatIds: string[]) { + // Only reset seats that are physically BLOCKED back to AVAILABLE if needed + // For segment-based bookings, releasing is handled by JourneySegment deletion + } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise { const seats = await this.prisma.seat.findMany({ diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 275ab077f..88fd636b6 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -9,7 +9,6 @@ export interface SeatHoldRequest { passengerId: string; originStationId: string; destinationStationId: string; - fareQuoteId?: string; } export interface BookingConfirmRequest { @@ -27,28 +26,42 @@ export class EnhancedSeatsService { async holdSeats(request: SeatHoldRequest) { return this.prisma.$transaction(async (tx) => { - const segments = await this.segmentsService.getJourneySegments(request.scheduleId, request.originStationId, request.destinationStationId); + const segments = await this.segmentsService.getJourneySegments( + request.scheduleId, request.originStationId, request.destinationStationId, + ); + const reqFrom = Math.min(...segments.map(s => s.fromSequence)); + const reqTo = Math.max(...segments.map(s => s.toSequence)); for (const seatId of request.seatIds) { const seat = await tx.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new BadRequestException(`Seat ${seatId} not found`); + // Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the + // segment does not overlap (another passenger may occupy a different leg) if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`); - const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, seatId, segments); - if (overlaps.length > 0) throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`); + + const free = await this.segmentsService.isSeatFreeForLeg( + request.scheduleId, seatId, reqFrom, reqTo, + ); + if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`); } const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - // Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range - // Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId) - const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`; const seatHold = await tx.seatHold.create({ - data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt }, + data: { + scheduleId: request.scheduleId, + seatIds: request.seatIds, + passengerId: request.passengerId, + // Store leg in createdBy JSON — no fareQuoteId needed + createdBy: JSON.stringify({ + originStationId: request.originStationId, + destinationStationId: request.destinationStationId, + }), + expiresAt, + }, }); - await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); - + // Do NOT set seat.status = HELD globally — status is segment-scoped this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments }); - return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds }; }); } @@ -68,19 +81,16 @@ export class EnhancedSeatsService { }); if (!schedule) throw new BadRequestException('Schedule not found'); - // Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId") - const legKey = hold.fareQuoteId ?? ''; + // Resolve the passenger's leg from createdBy JSON let originStationId: string | undefined; let destinationStationId: string | undefined; - if (legKey.startsWith('leg:')) { - const parts = legKey.split(':'); - originStationId = parts[1]; - destinationStationId = parts[2]; - } else { - // Fall back to booking's own origin/destination if available - originStationId = (booking as any).originStationId; - destinationStationId = (booking as any).destinationStationId; - } + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy); + originStationId = meta.originStationId; + destinationStationId = meta.destinationStationId; + } + } catch { /* ignore */ } const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined; const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined; @@ -122,11 +132,10 @@ export class EnhancedSeatsService { } } - await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); + // Do NOT set seat.status = BOOKED globally — availability is segment-scoped await tx.seatHold.delete({ where: { id: request.holdId } }); this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments }); - return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments }; }); } @@ -171,6 +180,8 @@ export class EnhancedSeatsService { async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) { const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId); + const reqFrom = Math.min(...segments.map(s => s.fromSequence)); + const reqTo = Math.max(...segments.map(s => s.toSequence)); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -181,13 +192,20 @@ export class EnhancedSeatsService { const availableSeats = []; for (const assignment of schedule.coachAssignments) { for (const seat of assignment.coach.seats) { - const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments); - if (overlaps.length === 0 && seat.status === 'AVAILABLE') { + // Hard-blocked seats are never available + if (seat.status === 'BLOCKED') continue; + // Availability is determined purely by segment overlap — not global seat.status + const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo); + if (free) { availableSeats.push({ id: seat.id, label: seat.label, coach: assignment.coach.label, seatClass: assignment.coach.seatClass.name, row: seat.row, col: seat.col, + kind: seat.kind, + isWindow: seat.isWindow, + isAisle: seat.isAisle, + bedPosition: seat.bedPosition, }); } } diff --git a/apps/edr-passenger-api/src/modules/segments/segments.controller.ts b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts index 5a45e38c5..cd62a3064 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.controller.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts @@ -1,132 +1,51 @@ -import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common'; +import { Controller, Post, Get, Body, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { EnhancedSeatsService } from './enhanced-seats.service'; -import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto'; +import { ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto'; @ApiTags('Segment-based Seats') @Controller('segments/seats') export class SegmentSeatsController { constructor(private enhancedSeatsService: EnhancedSeatsService) {} - @Post('hold') - @ApiOperation({ - summary: 'Hold seats for specific journey segments', - description: 'Reserve seats for a partial journey (e.g., Addis Ababa → Dire Dawa) with 10-minute expiry' - }) - @ApiResponse({ - status: 201, - description: 'Seats held successfully', - schema: { - example: { - holdId: 'hold_123', - expiresAt: '2024-01-15T10:10:00Z', - segments: [ - { fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 }, - { fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }, - { fromName: 'Awash', toName: 'Dire Dawa', fromSequence: 2, toSequence: 3 } - ], - seats: ['seat_1', 'seat_2'] - } - } - }) - @ApiResponse({ status: 409, description: 'Seats not available for requested segments' }) - async holdSeats(@Body() dto: HoldSeatsDto) { - return this.enhancedSeatsService.holdSeats({ - scheduleId: dto.scheduleId, - seatIds: dto.seatIds, - passengerId: dto.passengerId, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, - fareQuoteId: dto.fareQuoteId, - }); - } - @Post('confirm') - @ApiOperation({ - summary: 'Confirm booking and convert hold to reservation', - description: 'Convert seat hold to confirmed booking after payment success' + @ApiOperation({ + summary: 'Confirm booking — convert hold to reservation', + description: 'Call after payment succeeds. Converts the SeatHold (created via POST /seats/hold) into JourneySegment records scoped to the passenger\'s leg.', }) - @ApiResponse({ - status: 200, - description: 'Booking confirmed successfully', - schema: { - example: { - bookingId: 'booking_123', - confirmedSeats: ['seat_1', 'seat_2'], - segments: [ - { fromName: 'Addis Ababa', toName: 'Adama' }, - { fromName: 'Adama', toName: 'Awash' }, - { fromName: 'Awash', toName: 'Dire Dawa' } - ] - } - } - }) - @ApiResponse({ status: 400, description: 'Hold expired or not found' }) - async confirmBooking(@Body() dto: ConfirmBookingDto) { + @ApiResponse({ status: 200, description: 'Booking confirmed, JourneySegments created for the held leg' }) + @ApiResponse({ status: 400, description: 'Hold expired or booking not found' }) + confirmBooking(@Body() dto: ConfirmBookingDto) { return this.enhancedSeatsService.confirmBooking(dto); } @Post('release') - @ApiOperation({ - summary: 'Release seats when train reaches station', - description: 'Automatically release seats for passengers who have reached their destination' + @ApiOperation({ + summary: 'Release seats when train reaches a station', + description: 'Called by the live tracking system when the train departs a station. Frees seats for passengers whose journey ended at that station.', }) - @ApiResponse({ - status: 200, - description: 'Seats released successfully', - schema: { - example: { - releasedSeats: ['seat_1', 'seat_2'], - stationId: 'st_DRE' - } - } - }) - async releaseSeats(@Body() dto: ReleaseSeatsDto) { + @ApiResponse({ status: 200, description: 'Seats released for passengers who reached their destination' }) + releaseSeats(@Body() dto: ReleaseSeatsDto) { return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId); } @Get('availability') - @ApiOperation({ - summary: 'Check seat availability for journey segments', - description: 'Get available seats for a specific origin-destination pair' + @ApiOperation({ + summary: 'Get available seats for a specific leg', + description: 'Returns seats that have no overlapping reservation for the requested origin→destination leg. A seat booked A→B is shown as available for B→D.', }) - @ApiResponse({ - status: 200, - description: 'Seat availability retrieved', - schema: { - example: { - segments: [ - { fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 }, - { fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 } - ], - availableSeats: [ - { id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' }, - { id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' } - ], - totalAvailable: 2 - } - } - }) - async getSeatAvailability(@Query() dto: SeatAvailabilityDto) { + @ApiResponse({ status: 200, description: 'Available seats with coach, seat class, row, col, window/aisle/bed flags' }) + getSeatAvailability(@Query() dto: SeatAvailabilityDto) { return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId); } @Post('expire-holds') - @ApiOperation({ - summary: 'Expire old seat holds (background job)', - description: 'Release seats from expired holds and make them available' + @ApiOperation({ + summary: 'Expire stale seat holds (background job)', + description: 'Removes holds past their expiry time. Called by the scheduler every minute.', }) - @ApiResponse({ - status: 200, - description: 'Expired holds processed', - schema: { - example: { - expiredHolds: 5, - releasedSeats: ['seat_1', 'seat_2', 'seat_3'] - } - } - }) - async expireHolds() { + @ApiResponse({ status: 200, description: 'Expired holds removed' }) + expireHolds() { return this.enhancedSeatsService.expireHolds(); } } \ No newline at end of file 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 8fe525bcb..2eef0302e 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -26,7 +26,7 @@ export class SegmentsService { }); const originStop = stopTimes.find(st => st.stationId === originStationId); - const destStop = stopTimes.find(st => st.stationId === destinationStationId); + const destStop = stopTimes.find(st => st.stationId === destinationStationId); if (!originStop || !destStop) { throw new BadRequestException('Origin or destination station not found on this schedule'); @@ -38,22 +38,126 @@ export class SegmentsService { 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); + 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, + toStationId: toStop.stationId, + fromSequence: fromStop.sequence, + toSequence: toStop.sequence, + fromName: fromStop.station.name, + toName: toStop.station.name, }); } } return segments; } - /** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */ + /** + * 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) { @@ -62,69 +166,4 @@ export class SegmentsService { } return false; } - - /** - * Returns conflicts for a seat on a schedule for the requested segment range. - * Checks: - * 1. Active SeatHolds — resolved to sequence range via JourneySegment if available, - * otherwise treated as full-schedule block. - * 2. Active BookingSeats — resolved via JourneySegment sequence ranges. - */ - async getOverlappingReservations( - scheduleId: string, - seatId: string, - requestedSegments: Segment[], - ) { - const overlaps: { type: string; id: string }[] = []; - const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence)); - const reqTo = Math.max(...requestedSegments.map(s => s.toSequence)); - - // ── 1. Active holds ────────────────────────────────────────────────────── - const activeHolds = await this.prisma.seatHold.findMany({ - where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } }, - }); - - for (const hold of activeHolds) { - // Resolve hold range from JourneySegments created at hold time - const holdSegs = await this.prisma.journeySegment.findMany({ - where: { scheduleId, seatId }, - include: { schedule: { include: { stopTimes: true } } }, - }); - - if (holdSegs.length === 0) { - // No journey segments yet — conservative: treat as full-schedule conflict - overlaps.push({ type: 'hold', id: hold.id }); - continue; - } - - for (const js of holdSegs) { - const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence; - const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence; - if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) { - overlaps.push({ type: 'hold', id: hold.id }); - break; - } - } - } - - // ── 2. Active bookings via JourneySegment ──────────────────────────────── - const bookedSegments = await this.prisma.journeySegment.findMany({ - where: { - scheduleId, - seatId, - journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, - }, - include: { schedule: { include: { stopTimes: true } } }, - }); - - for (const js of bookedSegments) { - const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence; - const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence; - if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) { - overlaps.push({ type: 'booking', id: js.journeyId }); - } - } - - return overlaps; - } }