import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import * as QRCode from 'qrcode'; interface OfflineValidation { bookingRef: string; validatorId: string; gateId?: string; validatedAt: string; leg?: string; } @Injectable() export class TicketsService { constructor(private prisma: PrismaService) {} async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: 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' } }, { booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } }, ]; } if (filters.status) { where.booking = { ...where.booking, status: filters.status }; } if (filters.originStationId) { where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; } if (filters.destinationStationId) { where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; } 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 } } }; } const tickets = await this.prisma.ticket.findMany({ where, include: { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }, }, skip: filters.skip, take: filters.take, orderBy: { issuedAt: 'desc' }, }); const total = await this.prisma.ticket.count({ where }); return { items: tickets.map((t) => ({ id: t.id, ticketNumber: t.barcodePayload, bookingRef: t.bookingRef, booking: { bookingRef: t.booking.bookingRef, status: t.booking.status, bookingType: t.booking.bookingType, returnLegStatus: (t.booking as any).returnLegStatus ?? null, outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, totalMinor: t.booking.totalMinor, currency: t.booking.currency, displayCurrency: t.booking.displayCurrency, displayTotalMinor: t.booking.displayTotalMinor, passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, contactEmail: t.booking.contactEmail, }, schedule: t.booking.schedule, seat: t.booking.seats[0]?.seat, status: t.status, validatedAt: t.validatedAt, createdAt: t.issuedAt, })), total, skip: filters.skip, take: filters.take, }; } 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 } } } }, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); // Build a compact multi-leg payload for the QR so gate scanners see all legs const legSummary = this.buildLegSummary(booking); const qrData = JSON.stringify({ ref: booking.bookingRef, type: booking.bookingType, legs: legSummary, }); const qrPayload = await QRCode.toDataURL(qrData); const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const ticket = await this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload, barcodePayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, }); // Block all seats across all legs const seatIds = booking.seats.map(bs => bs.seatId); 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 ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, }).catch(() => null); } return { ...ticket, legs: legSummary }; } private buildLegSummary(booking: any) { const seatsByLeg = new Map(); for (const bs of booking.seats) { const leg = bs.leg ?? 1; if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []); seatsByLeg.get(leg)!.push(bs); } return Array.from(seatsByLeg.entries()) .sort(([a], [b]) => a - b) .map(([leg, seats]) => ({ leg, scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId, passengers: seats.map(bs => ({ name: bs.passengerName, category: bs.passengerCategory, coach: bs.seat?.coach?.number, seat: bs.seat?.seatNumber, fareMinor: bs.fareMinor, })), })); } async updateSeats(bookingId: string, newSeatIds: string[]) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { seats: true, ticket: true }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.ticket) throw new BadRequestException('No ticket found for this booking'); // Remove old seat blocks const oldSeatIds = booking.seats.map(bs => bs.seatId); for (const seatId of oldSeatIds) { await this.prisma.seatBlock.deleteMany({ where: { seatId, reason: { contains: booking.ticket.id } } }); } // Remove old booking seats await this.prisma.bookingSeat.deleteMany({ where: { bookingId } }); // Create new seat blocks for (const seatId of newSeatIds) { await this.prisma.seatBlock.create({ data: { seatId, reason: `Permanently booked in ticket ${booking.ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM', } }).catch(() => null); } // Create new booking seats (placeholder with minimal data) for (let i = 0; i < newSeatIds.length; i++) { await this.prisma.bookingSeat.create({ data: { bookingId, seatId: newSeatIds[i], passengerName: `Passenger ${i + 1}`, } }); } return { success: true, updatedSeats: newSeatIds.length }; } async getByMerchantOrderId(merchantOrderId: string) { const intent = await this.prisma.paymentIntent.findUnique({ where: { merchantOrderId }, select: { bookingId: true }, }); if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`); const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true }, }); if (!booking?.ticket) throw new NotFoundException('Ticket not found'); const seat = booking.seats[0]; return { id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName, priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, }; } async getByRef(bookingRef: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true }, }); if (!booking?.ticket) throw new NotFoundException('Ticket not found'); const seat = booking.seats[0]; return { id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName, priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload }; } 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.findUnique({ 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, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); 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' } }); 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, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); 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 used'); } bookingData.outboundBoardedAt = now; if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); } 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 used'); } bookingData.returnBoardedAt = now; } else { throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN'); } const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2 if (type === 'ROUND_TRIP_TRANSIT') { const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2']; const resolvedLeg = (leg ?? '').toUpperCase(); if (!validLegs.includes(resolvedLeg)) { throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`); } const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); if (logs.some(l => l.leg === resolvedLeg)) { 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`); } const bookingData: Record = {}; if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) { bookingData.outboundBoardedAt = now; } if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) { bookingData.returnBoardedAt = now; } const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED'; else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; if (Object.keys(bookingData).length) 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, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // Fallback for unknown booking types — single scan 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, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } 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: { ticket: true, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }); return bookings.map((b) => ({ bookingRef: b.bookingRef, ticketId: b.ticket?.id, passengerName: b.seats[0]?.passengerName, seatLabel: b.seats[0]?.seat.seatNumber, coachLabel: b.seats[0]?.seat.coach.number, qrPayload: b.ticket?.qrPayload, status: b.status, bookingType: b.bookingType, returnLegStatus: (b as any).returnLegStatus ?? null, validatedAt: b.ticket?.validatedAt, })); } async validateOfflineBatch(validations: OfflineValidation[]) { const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] }; const processedRefs = new Set(); for (const v of validations) { const offlineLeg = v.leg; const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef; if (processedRefs.has(dedupKey)) { results.duplicate++; continue; } processedRefs.add(dedupKey); try { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } }); if (!booking) { results.failed++; results.errors.push(`Booking ${v.bookingRef} not found`); continue; } const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); if (!ticket) { results.failed++; results.errors.push(`Ticket for ${v.bookingRef} not found`); continue; } if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' && booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') { results.duplicate++; continue; } // For multi-leg bookings, check per-leg duplication const isMultiLeg = booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'TRANSIT' || booking.bookingType === 'ROUND_TRIP_TRANSIT'; if (isMultiLeg && offlineLeg) { const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); if (existingLogs.some(l => l.leg === offlineLeg)) { results.duplicate++; continue; } } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId }, }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: v.validatorId, gateId: v.gateId, leg: v.leg ?? null, status: 'APPROVED', validatedAt: new Date(v.validatedAt), } as any, }); // update boarding timestamps for multi-leg bookings const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'TRANSIT' || booking.bookingType === 'ROUND_TRIP_TRANSIT'; if (isMultiLegBooking && offlineLeg) { const bookingData: Record = {}; const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1'; const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2'; if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt); if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt); if (Object.keys(bookingData).length) { await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); } } results.success++; } catch (err) { results.failed++; results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`); } } return results; } 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 }; } }