import { Injectable, NotFoundException, BadRequestException, ConflictException, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; import * as QRCode from 'qrcode'; interface OfflineValidation { bookingRef: string; validatorId: string; gateId?: string; validatedAt: string; leg?: string; } @Injectable() export class TicketsService { private readonly logger = new Logger(TicketsService.name); constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly systemConfig: SystemConfigService, private readonly auditService: AuditService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ { bookingRef: { contains: filters.search, mode: 'insensitive' } }, { barcodePayload: { contains: filters.search, mode: 'insensitive' } }, { passengerName: { contains: filters.search, mode: 'insensitive' } }, { booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } }, ]; } if (filters.status) { where.status = filters.status; } if (filters.originStationId) { where.booking = { ...where.booking, originStationId: filters.originStationId }; } if (filters.destinationStationId) { where.booking = { ...where.booking, destinationStationId: filters.destinationStationId }; } if (filters.departureDate) { const start = new Date(filters.departureDate); const end = new Date(filters.departureDate); end.setDate(end.getDate() + 1); where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } }; } if (filters.arrivalDate) { const start = new Date(filters.arrivalDate); const end = new Date(filters.arrivalDate); end.setDate(end.getDate() + 1); where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } if (filters.dateFrom || filters.dateTo) { where.issuedAt = { ...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}), ...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}), }; } if (filters.coachId) { where.seat = { coachId: filters.coachId }; } const [tickets, total] = await Promise.all([ this.prisma.ticket.findMany({ where, include: { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, passenger: { include: { travelerProfiles: true } }, seats: { include: { seat: { include: { coach: true } } } }, }, }, seat: { include: { coach: { include: { coachType: true } } } }, } as any, skip: filters.skip, take: filters.take, orderBy: { issuedAt: 'desc' }, }) as any, this.prisma.ticket.count({ where }), ]); const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, [iamUserIds], ) : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); return { items: tickets.map((t: any) => { const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined; // Extract phone from TravelerProfile notes JSON let guestPhone = null; let guestEmail = null; const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName); if (matchingProfile?.notes) { try { const notesData = JSON.parse(matchingProfile.notes); guestPhone = notesData.phone || null; guestEmail = notesData.email || null; } catch (err) { this.logger.error(`Failed to parse notes JSON: ${err}`); } } // Fallback to booking contact info if no match in TravelerProfile if (!guestPhone) guestPhone = t.booking?.contactPhone; if (!guestEmail) guestEmail = t.booking?.contactEmail; const passengerInfo = iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId); return { id: t.id, ticketNumber: t.barcodePayload, bookingRef: t.bookingRef, passengerName: t.passengerName, leg: t.leg, booking: { id: t.booking?.id, bookingRef: t.booking?.bookingRef, status: t.booking?.status, bookingType: t.booking?.bookingType, returnLegStatus: t.booking?.returnLegStatus ?? null, outboundBoardedAt: t.booking?.outboundBoardedAt ?? null, returnBoardedAt: t.booking?.returnBoardedAt ?? null, totalMinor: t.booking?.totalMinor, currency: t.booking?.currency, displayCurrency: t.booking?.displayCurrency, displayTotalMinor: t.booking?.displayTotalMinor, passenger: passengerInfo, contactEmail: t.booking?.contactEmail, contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], originStation: segment.origin, destinationStation: segment.destination, }, schedule: t.booking?.schedule ? { ...t.booking.schedule, departureAt: segment.departureAt, arrivalAt: segment.arrivalAt, } : null, seat: t.seat ? { id: t.seat.id, seatNumber: t.seat.seatNumber, coach: t.seat.coach ? { id: t.seat.coach.id, number: t.seat.coach.number, coachType: t.seat.coach.coachType ? { id: t.seat.coach.coachType.id, name: t.seat.coach.coachType.name, type: t.seat.coach.coachType.type, } : null, } : null, } : null, status: t.status, validatedAt: t.validatedAt, boardedAt: t.validatedAt, qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; }), total, skip: filters.skip, take: filters.take, }; } // Smart seat assignment for conflict resolution: // 1. If the original seat is still free → keep it and generate // 2. If the original seat is taken → find a truly available seat in the same coach type // (excludes: confirmed/boarded bookings, active holds, seat blocks, BOOKED/HELD/REMOVED status) // 3. If no seats of that class remain → throw so the agent is notified async smartAssignAndGenerate(bookingId: string) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { seats: { include: { seat: { include: { coach: { include: { coachType: true } } } }, }, }, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); // Seats held by any active SeatHold (not yet expired) const heldSeatIds = await this.prisma.seatHold.findMany({ where: { expiresAt: { gt: new Date() } }, select: { seatIds: true }, }).then(rows => new Set(rows.flatMap(r => r.seatIds))); // Seats with an active SeatBlock const blockedSeatIds = await this.prisma.seatBlock.findMany({ select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; // Track newly assigned seats so the same seat isn't given to two passengers const unavailableIds = new Set([ ...[...heldSeatIds], ...[...blockedSeatIds], ]); for (const bs of (booking as any).seats) { const originalSeatId: string = bs.seatId; // Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule, // not booking.scheduleId (the outbound schedule). const legScheduleId: string = bs.scheduleId ?? booking.scheduleId; // Seats taken by other confirmed/boarded bookings on THIS leg's schedule const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({ where: { booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } }, }, select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); // Case 1: original seat is still free on this leg — nothing to do if ( !takenByOthersOnLeg.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId) ) continue; // Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; const allUnavailable = new Set([ ...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)), ...[...unavailableIds], ]); const candidate = await this.prisma.seat.findFirst({ where: { status: 'AVAILABLE', seatNumber: { not: '' }, NOT: [ { seatNumber: { startsWith: '-' } }, { id: { in: [...allUnavailable] } }, ], coach: { assignments: { some: { scheduleId: legScheduleId } }, ...(coachTypeId ? { coachTypeId } : {}), }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); // Case 3: no seats left in that class on this leg if (!candidate) { const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; throw new ConflictException( `No available seats remaining in ${className} on this schedule. Please contact the passenger to arrange an alternative.`, ); } await this.prisma.bookingSeat.update({ where: { id: bs.id }, data: { seatId: candidate.id }, }); unavailableIds.add(candidate.id); reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } await this.auditService.log({ action: 'UPDATE', entityType: 'Booking', entityId: bookingId, newData: { smartReassigned: true, changes: reassigned }, }); return this.generate(bookingId); } async generate(bookingId: string) { if (!bookingId) throw new BadRequestException('Booking ID is required'); const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); // No payment intent record at all if (!(booking as any).paymentIntent) { throw new HttpException( { status: 'error', message: 'Payment not completed', code: 400 }, HttpStatus.BAD_REQUEST, ); } // Payment intent exists but not yet succeeded if ((booking as any).paymentIntent.status !== 'SUCCEEDED') { throw new HttpException( { status: 'error', message: 'Payment not completed', code: 400, detail: `Payment status: ${(booking as any).paymentIntent.status}`, }, HttpStatus.BAD_REQUEST, ); } // Booking not in CONFIRMED state — could be a webhook delivery failure. if (booking.status !== 'CONFIRMED') { if (booking.status === 'PENDING_PAYMENT') { this.logger.warn( `Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`, ); await this.prisma.booking.update({ where: { id: bookingId }, data: { status: 'CONFIRMED' }, }); } else { throw new HttpException( { status: 'error', message: 'Payment not completed', code: 400, detail: `Booking status: ${booking.status}`, }, HttpStatus.BAD_REQUEST, ); } } await this.prisma.ticket.deleteMany({ where: { bookingId } }); // Remove any SeatBlock rows left over from a previous generate() run for this // booking — they reference the old ticket IDs which are now deleted, and would // otherwise cause the conflict check below to see this booking's own seats as // blocked by another booking. const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' }, }); // Check for seat conflicts — only seats confirmed/boarded by a *different* booking // on the SAME schedule AND with OVERLAPPING segments are a real conflict. // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges // overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq. // We resolve sequences via TripStopTime using each booking's originStationId / // destinationStationId. Bookings with no station IDs (full-route) are treated as // seq 0 → ∞ and always overlap. const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>; // Resolve this booking's stop sequences per leg schedule const thisSeqMap = new Map(); const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))]; for (const schedId of legScheduleIds) { const originId = (booking as any).originStationId; const destId = (booking as any).destinationStationId; if (!originId || !destId) { thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }); continue; } const stops = await this.prisma.tripStopTime.findMany({ where: { scheduleId: schedId, stationId: { in: [originId, destId] } }, select: { stationId: true, sequence: true }, }); const oStop = stops.find(s => s.stationId === originId); const dStop = stops.find(s => s.stationId === destId); thisSeqMap.set(schedId, { originSeq: oStop?.sequence ?? 0, destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER, }); } // Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair const candidateConflicts = await this.prisma.bookingSeat.findMany({ where: { OR: thisBookingSeats.map(bs => ({ seatId: bs.seatId, scheduleId: bs.scheduleId ?? booking.scheduleId, booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, })), }, include: { seat: true, booking: { select: { id: true, originStationId: true, destinationStationId: true } }, }, }); const trueConflicts: string[] = []; for (const other of candidateConflicts) { const legScheduleId = other.scheduleId ?? booking.scheduleId; const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }; const otherOriginId = (other.booking as any).originStationId; const otherDestId = (other.booking as any).destinationStationId; let otherOriginSeq = 0; let otherDestSeq = Number.MAX_SAFE_INTEGER; if (otherOriginId && otherDestId) { const stops = await this.prisma.tripStopTime.findMany({ where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } }, select: { stationId: true, sequence: true }, }); otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0; otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER; } // Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) { trueConflicts.push((other as any).seat.seatNumber); } } if (trueConflicts.length > 0) { const labels = [...new Set(trueConflicts)].join(', '); throw new ConflictException( `Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`, ); } // Generate one ticket per passenger per leg. // Round-trip / transit bookings have seats on multiple legs — each leg needs its own // ticket so the voucher can match by (passengerName, leg) and gate scanners can // validate each leg independently. const tickets = []; // Group seats by (passengerName, leg) const passengerLegSeatsMap = new Map(); for (const bookingSeat of (booking as any).seats) { const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`; if (!passengerLegSeatsMap.has(key)) { passengerLegSeatsMap.set(key, []); } passengerLegSeatsMap.get(key)!.push(bookingSeat); } // Create one ticket per (passenger, leg) for (const [key, legSeats] of passengerLegSeatsMap.entries()) { const [passengerName] = key.split('|'); const primarySeat = legSeats[0]; const leg = primarySeat.leg ?? 1; const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`; const qrDataWithTicket = JSON.stringify({ ref: booking.bookingRef, ticketNumber: barcodePayload, type: booking.bookingType, passenger: passengerName, leg, seats: legSeats.map(ps => ({ seat: ps.seat?.seatNumber, coach: ps.seat?.coach?.number, leg: ps.leg || 1, scheduleId: ps.scheduleId || booking.scheduleId, })), }); const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket); const ticket = await this.prisma.ticket.create({ data: { bookingId, bookingRef: booking.bookingRef, passengerName, seatId: primarySeat.seatId, leg, scheduleId: primarySeat.scheduleId || booking.scheduleId, qrPayload: qrPayloadFinal, barcodePayload, } as any, }); tickets.push(ticket); } // Block all seats across all legs for (const seatId of seatIds) { await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } }); await this.prisma.seatBlock.create({ data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, }).catch(() => null); } await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } }); return { tickets, totalTickets: tickets.length }; } async updateSeats(bookingId: string, seatIds: string[]) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { tickets: true, seats: true } as any }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); // Remove old seat blocks for (const ticket of (booking as any).tickets) { await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: ticket.id } } }); } // Delete existing tickets await this.prisma.ticket.deleteMany({ where: { bookingId } }); // Update booking seats await this.prisma.bookingSeat.deleteMany({ where: { bookingId } }); // Create new seat assignments (simplified) for (let i = 0; i < seatIds.length; i++) { await this.prisma.bookingSeat.create({ data: { bookingId, seatId: seatIds[i], passengerName: `Passenger ${i + 1}`, leg: 1 } as any }); } // Generate new tickets return this.generate(bookingId); } async getByMerchantOrderId(merchantOrderId: string) { const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { merchantOrderId }, include: { booking: { include: { tickets: true } as any } } as any }); if (!paymentIntent) throw new NotFoundException('Payment not found'); const booking = (paymentIntent as any).booking; if (!booking) throw new NotFoundException('Booking not found'); return this.getByRef(booking.bookingRef); } async getByRef(ref: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: ref }, include: { tickets: true, schedule: { include: { originStation: true, destinationStation: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, seats: { include: { seat: { include: { coach: true } } } } } as any }); if (!booking) throw new NotFoundException('Booking not found'); return { booking: { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, bookingType: booking.bookingType, totalMinor: booking.totalMinor, currency: booking.currency }, tickets: (booking as any).tickets, schedule: (booking as any).schedule, returnSchedule: (booking as any).returnSchedule, seats: (booking as any).seats }; } async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) { try { // Extract booking reference from QR code if it's JSON let bookingRef = qrCodeOrRef; try { const qrData = JSON.parse(qrCodeOrRef); if (qrData.ref) bookingRef = qrData.ref; } catch { // Not JSON, treat as booking reference or ticket number } // Get booking and ticket info let booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, tickets: true, seats: { include: { seat: { include: { coach: true } } } }, }, }); if (!booking) { // Input may be a ticket number (barcodePayload) — look it up const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } }); if (ticket) { booking = await this.prisma.booking.findUnique({ where: { bookingRef: ticket.bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, tickets: true, seats: { include: { seat: { include: { coach: true } } } }, }, }); if (booking) bookingRef = (booking as any).bookingRef; } } if (!booking) { throw new NotFoundException('Ticket not found'); } if (booking.status !== 'CONFIRMED') { throw new BadRequestException('Ticket is not confirmed'); } const ticket = (booking as any).tickets[0]; if (!ticket) { throw new NotFoundException('No ticket found for this booking'); } // Check if ticket date matches today. Boarding window is relative to the // passenger's actual boarding stop, not the train's origin — for a mid-route // boarding these differ. const today = new Date(); const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); if (boardingSegment.departureAt) { const departureTime = new Date(boardingSegment.departureAt); const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); if (today < boardingOpenTime) { throw new BadRequestException( `Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`, ); } if (today >= departureTime) { throw new BadRequestException('Boarding is closed — departure time has passed'); } } // Use existing validation logic to handle round trips properly const result = await this.validate(bookingRef, validatorId, gateId); // Get seat information const seatInfo = (booking as any).seats[0]; const seatNumber = seatInfo?.seat?.seatNumber || 'N/A'; const coachNumber = seatInfo?.seat?.coach?.number || 'N/A'; // Send notifications after successful boarding await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND'); return { success: true, message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, boarding: { ticketId: ticket.id, ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`, seat: seatNumber, coach: coachNumber, trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A', departureTime: boardingSegment.departureAt, boardedAt: result.validatedAt, leg: result.leg || 'OUTBOUND', bookingType: booking.bookingType, isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT', }, }; } catch (error) { // Return structured error for the UI const errorMessage = error instanceof Error ? error.message : 'Boarding failed'; const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR' : error instanceof NotFoundException ? 'NOT_FOUND' : 'SYSTEM_ERROR'; return { success: false, error: errorMessage, errorCode, }; } } private async sendBoardingNotifications(booking: any, ticket: any, leg: string) { try { const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger'; const contactEmail = booking.contactEmail; const contactPhone = booking.contactPhone; if (!contactEmail && !contactPhone) { this.logger.warn(`No contact details found for booking ${booking.bookingRef}`); return; } const routeInfo = `${booking.schedule?.originStation?.name} → ${booking.schedule?.destinationStation?.name}`; const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number; const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A'; const legText = leg === 'RETURN' ? 'Return' : 'Outbound'; // Use the existing sendBoardingPassNotification method await this.notifications.sendBoardingPassNotification({ passengerId: booking.passengerId || null, contactEmail, contactPhone, bookingRef: booking.bookingRef, leg, booking, ticket, }); } catch (error: any) { this.logger.error('Error sending boarding notifications:', error); } } async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) { // Accept either a ticket UUID or a bookingRef let bookingRef = ticketIdOrRef; const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef); if (isUuid) { const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } }); if (!ticket) throw new NotFoundException('Ticket not found'); bookingRef = ticket.bookingRef; } const resolvedValidatorId = validatorId || 'BACKOFFICE'; const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); if (!ticket) throw new NotFoundException('Ticket not found'); const type = booking.bookingType; const now = new Date(); // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); this.fireBoardingPassNotification(booking, ticket, null); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } // ── TRANSIT — leg=LEG1 or leg=LEG2 ────────────────────────────────── if (type === 'TRANSIT') { const resolvedLeg = (leg ?? 'LEG1').toUpperCase(); if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') { throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2'); } const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[]; const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── if (type === 'ROUND_TRIP') { let resolvedLeg = (leg ?? '').toUpperCase(); // Auto-detect next unused leg when called from backoffice without a leg param if (!resolvedLeg) { resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN'; } const bookingData: Record = {}; if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); throw new BadRequestException('Outbound leg already validated'); } bookingData.outboundBoardedAt = now; } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); throw new BadRequestException('Return leg already validated'); } bookingData.returnBoardedAt = now; } else { throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN'); } await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); if (!ticket.validatedAt) { await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } }); } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } throw new BadRequestException(`Unsupported booking type: ${type}`); } private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) { this.logger.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`); } async getValidationLogs(ticketId: string) { return this.prisma.gateValidationLog.findMany({ where: { ticketId }, orderBy: { validatedAt: 'desc' } }); } async exportOfflineData(tripId: string) { const bookings = await this.prisma.booking.findMany({ where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { tickets: true, seats: { include: { seat: { include: { coach: true } } } }, passenger: { select: { id: true, iamUserId: true } }, } as any, }); return bookings.map((b) => ({ bookingRef: b.bookingRef, ticketId: (b as any).tickets?.[0]?.id, passengerName: (b as any).seats[0]?.passengerName, seatLabel: (b as any).seats[0]?.seat.seatNumber, coachLabel: (b as any).seats[0]?.seat.coach.number, qrPayload: (b as any).tickets?.[0]?.qrPayload, status: b.status, bookingType: b.bookingType, returnLegStatus: (b as any).returnLegStatus ?? null, validatedAt: (b as any).tickets?.[0]?.validatedAt, })); } async validateOfflineBatch(validations: OfflineValidation[]) { const results = []; for (const validation of validations) { try { const result = await this.validate( validation.bookingRef, validation.validatorId, validation.gateId, validation.leg ); results.push({ bookingRef: validation.bookingRef, success: true, result }); } catch (error) { results.push({ bookingRef: validation.bookingRef, success: false, error: error instanceof Error ? error.message : 'Validation failed' }); } } return { processed: results.length, successful: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length, results }; } async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> { const missingWhere = { status: 'CONFIRMED' as const, tickets: { none: {} }, paymentIntent: { status: 'SUCCEEDED' as const }, }; const [confirmedWithNoTickets, totalRemaining] = await Promise.all([ this.prisma.booking.findMany({ where: missingWhere, select: { id: true, bookingRef: true }, take: limit, }), this.prisma.booking.count({ where: missingWhere }), ]); const details: any[] = []; let generated = 0; let failed = 0; for (const booking of confirmedWithNoTickets) { try { await this.smartAssignAndGenerate(booking.id); generated++; details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' }); } catch (err) { failed++; details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) }); } } return { processed: confirmedWithNoTickets.length, generated, failed, remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length), details, }; } async delete(id: string) { const ticket = await this.prisma.ticket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('Ticket not found'); await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } }); // Remove seat blocks associated with this ticket await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: id } } }); await this.prisma.ticket.delete({ where: { id } }); return { deleted: true, ticketId: id }; } async restore(id: string) { const ticket = await this.prisma.ticket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('Ticket not found'); return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } }); } }