import { Injectable, BadRequestException, ConflictException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SegmentsService, Segment } from '../segments/segments.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; export interface SeatHoldRequest { scheduleId: string; seatIds: string[]; passengerId: string; originStationId: string; destinationStationId: string; } export interface BookingConfirmRequest { holdId: string; bookingId: string; } @Injectable() export class EnhancedSeatsService { private readonly logger = new Logger(EnhancedSeatsService.name); constructor( private prisma: PrismaService, private segmentsService: SegmentsService, private eventEmitter: EventEmitter2, ) {} async holdSeats(request: SeatHoldRequest) { return this.prisma.$transaction(async (tx) => { 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`); if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`); const free = await this.segmentsService.isSeatFreeForLeg( request.scheduleId, seatId, reqFrom, reqTo, ); if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`); } const expiresAt = new Date(Date.now() + 10 * 60 * 1000); const seatHold = await tx.seatHold.create({ data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, createdBy: JSON.stringify({ originStationId: request.originStationId, destinationStationId: request.destinationStationId, }), expiresAt, }, }); // Mirrors SeatsService.holdSeats() — without this, a seat held through this path // reads back as status 'AVAILABLE' in the DB despite being actively held, which is // wrong for any consumer that trusts `status` directly instead of re-deriving // availability live from SeatHold. await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt }, }); this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments }); return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds }; }); } async confirmBooking(request: BookingConfirmRequest) { return this.prisma.$transaction(async (tx) => { const hold = await tx.seatHold.findUnique({ where: { id: request.holdId } }); if (!hold) throw new BadRequestException('Seat hold not found'); if (hold.expiresAt < new Date()) throw new BadRequestException('Seat hold has expired'); const booking = await tx.booking.findUnique({ where: { id: request.bookingId } }); if (!booking) throw new BadRequestException('Booking not found'); const schedule = await tx.trainSchedule.findUnique({ where: { id: hold.scheduleId }, include: { stopTimes: { orderBy: { sequence: 'asc' } } }, }); if (!schedule) throw new BadRequestException('Schedule not found'); let originStationId: string | undefined; let destinationStationId: string | undefined; 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: any) => s.stationId === originStationId) : undefined; const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined; const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence; const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence; const segments: Segment[] = []; for (let i = fromSeq; i < toSeq; i++) { const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i); const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1); if (fromStop && toStop) { segments.push({ fromStationId: fromStop.stationId, toStationId: toStop.stationId, fromSequence: fromStop.sequence, toSequence: toStop.sequence, fromName: '', toName: '', }); } } const journey = await tx.journey.create({ data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency }, }); for (const seatId of hold.seatIds) { for (let i = 0; i < segments.length; i++) { await tx.journeySegment.create({ data: { journeyId: journey.id, scheduleId: hold.scheduleId, segmentOrder: i + 1, seatId, departureStationId: segments[i].fromStationId, arrivalStationId: segments[i].toStationId, }, }); } } 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 }; }); } async releaseSeats(scheduleId: string, currentStationId: string) { return this.prisma.$transaction(async (tx) => { const completedSegments = await tx.journeySegment.findMany({ where: { scheduleId, arrivalStationId: currentStationId }, include: { journey: { include: { journeySegments: { where: { scheduleId } } } } }, }); const seatsToRelease: string[] = []; for (const segment of completedSegments) { const allSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId); const maxSegmentOrder = Math.max(...allSegments.map((js: any) => js.segmentOrder)); if (segment.segmentOrder === maxSegmentOrder) seatsToRelease.push(segment.seatId!); } if (seatsToRelease.length > 0) { await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } }); this.eventEmitter.emit('seats.released', { scheduleId, stationId: currentStationId, releasedSeats: seatsToRelease }); } return { releasedSeats: seatsToRelease, stationId: currentStationId }; }); } // now/expiresAt are absolute UTC instants (Date objects), not wall-clock strings, so // this comparison is correct regardless of the server's local timezone. async expireHolds(now: Date = new Date()) { try { const result = await this.prisma.$transaction(async (tx) => { const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: now } } }); if (expiredHolds.length === 0) { return { expiredHolds: 0, releasedSeats: [] as string[] }; } // Still-active holds — scoped per (scheduleId, seatId). The same physical Seat // row is reused across every recurring date a coach runs, so the same seatId can // legitimately appear in an unrelated hold for a different schedule; without this // scoping, that unrelated hold would wrongly be treated as covering THIS // schedule's seat too, and a seat still genuinely held (same schedule, a newer // non-expired hold) could be released out from under it. const activeHolds = await tx.seatHold.findMany({ where: { expiresAt: { gte: now } } }); const stillHeldKeys = new Set( activeHolds.flatMap(h => h.seatIds.map(seatId => `${h.scheduleId}:${seatId}`)), ); const releasedSeatIds = new Set(); for (const hold of expiredHolds) { for (const seatId of hold.seatIds) { if (!stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) releasedSeatIds.add(seatId); } } if (releasedSeatIds.size > 0) { await tx.seat.updateMany({ where: { id: { in: Array.from(releasedSeatIds) } }, data: { status: 'AVAILABLE', heldUntil: null }, }); } await tx.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); return { expiredHolds: expiredHolds.length, releasedSeats: Array.from(releasedSeatIds) }; }); if (result.expiredHolds > 0) { this.logger.log(`Expired ${result.expiredHolds} hold(s), released ${result.releasedSeats.length} seat(s)`); this.eventEmitter.emit('holds.expired', { expiredHolds: result.expiredHolds, releasedSeats: result.releasedSeats }); } return result; } catch (error) { // A failed run must not go unnoticed — nothing is deleted/updated until the // transaction commits, so the next caller/scheduled run simply retries the same // still-expired holds. this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error); return { expiredHolds: 0, releasedSeats: [] as string[] }; } } 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 }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } } }, }); if (!schedule) throw new BadRequestException('Schedule not found'); const availableSeats = []; for (const assignment of schedule.coachAssignments) { for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED') continue; const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo); if (free) { availableSeats.push({ id: seat.id, label: seat.seatNumber, coach: assignment.coach.number, seatClass: 'Standard', row: seat.row, col: seat.col, kind: seat.kind, isWindow: seat.isWindow, isAisle: seat.isAisle, bedPosition: seat.bedPosition, }); } } } return { segments, availableSeats, totalAvailable: availableSeats.length }; } }