mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -39,6 +39,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@@ -49,6 +50,7 @@ export class TicketsController {
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
@@ -60,6 +62,7 @@ export class TicketsController {
|
||||
arrivalDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
@@ -83,6 +86,31 @@ export class TicketsController {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
},
|
||||
},
|
||||
})
|
||||
scanAndBoard(
|
||||
@Param('qrCodeOrRef') qrCodeOrRef: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string,
|
||||
) {
|
||||
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -23,12 +23,13 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: 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' } } },
|
||||
];
|
||||
}
|
||||
@@ -53,6 +54,12 @@ export class TicketsService {
|
||||
...(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,
|
||||
@@ -61,19 +68,20 @@ export class TicketsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: 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 => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
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)`,
|
||||
@@ -83,34 +91,49 @@ export class TicketsService {
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
return {
|
||||
items: tickets.map((t) => {
|
||||
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
items: tickets.map((t: any) => {
|
||||
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
const passengerInfo = iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
|
||||
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
|
||||
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 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,
|
||||
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 as any).returnSchedule ?? null,
|
||||
contactEmail: t.booking?.contactEmail,
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
schedule: t.booking?.schedule,
|
||||
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,
|
||||
createdAt: t.issuedAt,
|
||||
@@ -136,7 +159,7 @@ export class TicketsService {
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// No payment intent record at all
|
||||
if (!booking.paymentIntent) {
|
||||
if (!(booking as any).paymentIntent) {
|
||||
throw new HttpException(
|
||||
{ status: 'error', message: 'Payment not completed', code: 400 },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
@@ -144,21 +167,19 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
// Payment intent exists but not yet succeeded
|
||||
if (booking.paymentIntent.status !== 'SUCCEEDED') {
|
||||
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Payment status: ${booking.paymentIntent.status}`,
|
||||
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||
// self-heal here rather than rejecting a legitimately paid booking.
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
this.logger.warn(
|
||||
@@ -181,154 +202,282 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
// Delete existing tickets if any
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
const tickets = [];
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
// Build passenger QR data with all legs included
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
seat: ps.seat.seatNumber,
|
||||
coach: ps.seat.coach.number,
|
||||
leg: ps.leg || 1,
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: {
|
||||
bookingId,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload,
|
||||
barcodePayload,
|
||||
} as any,
|
||||
});
|
||||
tickets.push(ticket);
|
||||
}
|
||||
|
||||
// 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' },
|
||||
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
return { ...ticket, legs: legSummary };
|
||||
return { tickets, totalTickets: tickets.length };
|
||||
}
|
||||
|
||||
private buildLegSummary(booking: any) {
|
||||
const seatsByLeg = new Map<number, any[]>();
|
||||
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[]) {
|
||||
async updateSeats(bookingId: string, seatIds: string[]) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, ticket: true },
|
||||
include: { tickets: true, seats: true } as any
|
||||
});
|
||||
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) {
|
||||
for (const ticket of (booking as any).tickets) {
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: {
|
||||
seatId,
|
||||
reason: { contains: booking.ticket.id }
|
||||
}
|
||||
where: { reason: { contains: ticket.id } }
|
||||
});
|
||||
}
|
||||
|
||||
// Remove old booking seats
|
||||
// Delete existing tickets
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Update 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++) {
|
||||
|
||||
// Create new seat assignments (simplified)
|
||||
for (let i = 0; i < seatIds.length; i++) {
|
||||
await this.prisma.bookingSeat.create({
|
||||
data: {
|
||||
bookingId,
|
||||
seatId: newSeatIds[i],
|
||||
seatId: seatIds[i],
|
||||
passengerName: `Passenger ${i + 1}`,
|
||||
}
|
||||
leg: 1
|
||||
} as any
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, updatedSeats: newSeatIds.length };
|
||||
// Generate new tickets
|
||||
return this.generate(bookingId);
|
||||
}
|
||||
|
||||
async getByMerchantOrderId(merchantOrderId: string) {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
const paymentIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
select: { bookingId: true },
|
||||
include: { booking: { include: { tickets: true } as any } } as any
|
||||
});
|
||||
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
|
||||
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: { id: intent.bookingId },
|
||||
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
|
||||
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?.ticket) throw new NotFoundException('Ticket not found');
|
||||
const seat = booking.seats[0];
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
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,
|
||||
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 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 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
|
||||
}
|
||||
|
||||
// Get booking and ticket info
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
const today = new Date();
|
||||
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureDate = new Date((booking as any).schedule.departureAt);
|
||||
const departureDateStr = departureDate.toISOString().split('T')[0];
|
||||
|
||||
// Check if ticket is for today
|
||||
if (departureDateStr !== todayDateStr) {
|
||||
if (departureDateStr < todayDateStr) {
|
||||
throw new BadRequestException('Ticket has expired - departure date has passed');
|
||||
} else {
|
||||
throw new BadRequestException('Ticket is for a future date - cannot board early');
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: ticket expires 4 hours after departure time
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
|
||||
if (today > expiryTime) {
|
||||
throw new BadRequestException('Ticket has expired - boarding window closed');
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||
seat: seatNumber,
|
||||
coach: coachNumber,
|
||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||
departureTime: (booking as any).schedule?.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) {
|
||||
@@ -343,11 +492,11 @@ export class TicketsService {
|
||||
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 } });
|
||||
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();
|
||||
const now = new Date();
|
||||
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
@@ -389,92 +538,34 @@ export class TicketsService {
|
||||
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');
|
||||
throw new BadRequestException('Outbound leg already validated');
|
||||
}
|
||||
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');
|
||||
throw new BadRequestException('Return leg already validated');
|
||||
}
|
||||
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 });
|
||||
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 });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
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' } }) as any[];
|
||||
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<string, any> = {};
|
||||
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 });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
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' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
throw new BadRequestException(`Unsupported booking type: ${type}`);
|
||||
}
|
||||
|
||||
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
|
||||
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
|
||||
this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
}).then((enriched) => {
|
||||
if (!enriched) return;
|
||||
this.notifications.sendBoardingPassNotification({
|
||||
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
|
||||
contactEmail: (enriched as any).contactEmail ?? null,
|
||||
contactPhone: (enriched as any).contactPhone ?? null,
|
||||
bookingRef: enriched.bookingRef,
|
||||
leg,
|
||||
booking: enriched,
|
||||
ticket,
|
||||
}).catch(() => null);
|
||||
}).catch(() => null);
|
||||
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
|
||||
// TODO: Implement notification logic
|
||||
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -488,111 +579,57 @@ export class TicketsService {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
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.ticket?.id,
|
||||
passengerName: b.seats[0]?.passengerName,
|
||||
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
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.ticket?.validatedAt,
|
||||
validatedAt: (b as any).tickets?.[0]?.validatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async validateOfflineBatch(validations: OfflineValidation[]) {
|
||||
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
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);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const validation of validations) {
|
||||
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' } }) as any[];
|
||||
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 },
|
||||
const result = await this.validate(
|
||||
validation.bookingRef,
|
||||
validation.validatorId,
|
||||
validation.gateId,
|
||||
validation.leg
|
||||
);
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
|
||||
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,
|
||||
} catch (error) {
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Validation failed'
|
||||
});
|
||||
|
||||
// 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<string, any> = {};
|
||||
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;
|
||||
|
||||
return {
|
||||
processed: results.length,
|
||||
successful: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
@@ -618,4 +655,4 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user