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, private segmentsService: SegmentsService, ) {} async getSeatMap(scheduleId: string, coachId?: string) { const assignments = await this.prisma.coachAssignment.findMany({ where: { scheduleId, ...(coachId ? { coachId } : {}) }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, coachType: { include: { seatClasses: true } }, }, }, }, orderBy: { positionNumber: 'asc' }, }); console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds); const response = { coaches: assignments.map((a) => { const allSeats = a.coach.seats; const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); return { id: a.coach.id, assignmentId: a.id, coachNumber: a.coach.number, label: a.coach.number, mode: a.coach.status, name: `Coach ${a.coach.number}`, seatClasses: seatClassNames, seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', positionNumber: a.positionNumber, seatArrangement: a.coach.arrangement, totalSeats: a.coach.capacity, seats: allSeats.map((s) => ({ id: s.id, seatNumber: s.seatNumber, number: s.seatNumber, label: s.seatNumber, 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, coach: { id: a.coach.id, coachNumber: a.coach.number, label: a.coach.number, }, })), }; }), }; console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`); return response; } async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], ): Promise> { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; 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'); } } 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; } async holdSeats(dto: HoldSeatsDto) { 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'); if (new Set(seatIds).size !== seatIds.length) throw new BadRequestException('Duplicate seatId in passengers list'); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const hold = await this.prisma.$transaction(async (tx) => { const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, select: { id: true, status: true, seatNumber: 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.seatNumber).join(', ')} are blocked`); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); 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'); if (reqFrom >= reqTo) throw new BadRequestException('Origin must come before destination'); const activeHolds = await tx.seatHold.findMany({ where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, select: { seatIds: true, createdBy: true }, }); 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 */ } } for (const { passengerId, seatId } of dto.passengers) { for (const hold of parsedHolds) { const legsOverlap = hold.from < reqTo && reqFrom < hold.to; if (!legsOverlap) continue; if (hold.seatIds.includes(seatId)) { throw new ConflictException( `Seat ${seatLabelById[seatId]} is already held for this leg`, ); } if (hold.passengerIds.includes(passengerId)) { throw new ConflictException( `Passenger already holds a seat on this journey leg`, ); } } } 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 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); } private async enrichHold(hold: any) { let originStationId: string | null = null; let destinationStationId: string | null = null; let passengerSeatMap: { passengerId: string; seatId: string }[] = []; try { if (hold.createdBy) { const raw = hold.createdBy; 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 */ } 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: 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; } const seatById = Object.fromEntries(seats.map(s => [s.id, s])); const passengers = passengerSeatMap.length > 0 ? passengerSeatMap.map(({ passengerId, seatId }) => { const s = seatById[seatId]; return { passengerId, seat: s ? { id: s.id, label: s.seatNumber, seatNumber: s.seatNumber, coach: s.coach.number, seatClass: 'Standard', row: s.row, col: s.col, } : { id: seatId }, }; }) : seatIds.map(seatId => { const s = seatById[seatId]; return { passengerId: hold.passengerId, seat: s ? { id: s.id, label: s.seatNumber, seatNumber: s.seatNumber, coach: s.coach.number, seatClass: 'Standard', 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.seatHold.delete({ where: { id: holdId } }); return { released: true, holdId }; } async confirmSeats(seatIds: string[]) { // No-op } async releaseSeats(seatIds: string[]) { if (seatIds.length > 0) { await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } }, }); } } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { coach: { assignments: { some: { scheduleId } } }, status: 'AVAILABLE', seatNumber: { not: '' }, NOT: { seatNumber: { startsWith: '-' } }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); if (seats.length < count) { throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`); } const assigned = this.findContiguousSeats(seats, count); return assigned.map((s) => s.id); } private findContiguousSeats(seats: any[], count: number): any[] { if (count === 1) return [seats[0]]; const grouped = new Map(); for (const seat of seats) { const key = `${seat.coachId}-${seat.row}`; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(seat); } for (const rowSeats of grouped.values()) { if (rowSeats.length >= count) { return rowSeats.slice(0, count); } } return seats.slice(0, count); } async exportSeatsCSV(scheduleId: string): Promise { const assignments = await this.prisma.coachAssignment.findMany({ where: { scheduleId }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, }); const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor']; for (const a of assignments) { for (const seat of a.coach.seats) { rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`); } } return rows.join('\n'); } async previewSeatsCSV(csvContent: string): Promise<{ valid: number; invalid: number; errors: string[] }> { const lines = csvContent.trim().split('\n').slice(1); const errors: string[] = []; let valid = 0; let invalid = 0; for (let i = 0; i < lines.length; i++) { const parts = lines[i].split(','); if (parts.length < 8) { errors.push(`Line ${i + 2}: Invalid format`); invalid++; continue; } const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; if (!coachId || !row || !col || !seatNumber) { errors.push(`Line ${i + 2}: Missing required fields`); invalid++; continue; } valid++; } return { valid, invalid, errors: errors.slice(0, 10) }; } async importSeatsCSV(scheduleId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> { const lines = csvContent.trim().split('\n').slice(1); const errors: string[] = []; let imported = 0; if (!commit) { return { imported: 0, errors: ['Preview mode'] }; } for (let i = 0; i < lines.length; i++) { try { const parts = lines[i].split(','); const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; await this.prisma.seat.upsert({ where: { coachId_row_col: { coachId, row: parseInt(row), col } }, update: { seatNumber, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, }, create: { coachId, row: parseInt(row), col, seatNumber, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, }, }); imported++; } catch (err) { errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`); } } return { imported, errors: errors.slice(0, 10) }; } async blockSeat(seatId: string, reason: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' }, }); await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system', }, }); return { blocked: true, seatId, reason }; } async unblockSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' }, }); await this.prisma.seatBlock.deleteMany({ where: { seatId }, }); return { unblocked: true, seatId }; } async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); // Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space const negatedNumber = `-${seat.seatNumber}`; await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: negatedNumber }, }); return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; } async undoRemoveSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) { throw new BadRequestException('Seat is not removed'); } // Restore original seatNumber by removing the negative sign const originalNumber = seat.seatNumber.slice(1); await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber }, }); return { restored: true, seatId, seatNumber: originalNumber }; } @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); for (const hold of expired) { await this.releaseSeats(hold.seatIds); try { await this.prisma.seatHold.delete({ where: { id: hold.id } }); } catch (err) { if (err instanceof Error && !err.message.includes('P2025')) { throw err; } } } } }