mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
959 lines
39 KiB
TypeScript
959 lines
39 KiB
TypeScript
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<string, { originSeq: number; destSeq: number }>();
|
|
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<string, any[]>();
|
|
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<string, any> = {};
|
|
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' } });
|
|
}
|
|
}
|