Round trip booking and additional enhancements

This commit is contained in:
Stephanos A
2026-06-17 13:42:20 +03:00
parent 81a9c614b7
commit 24ee3b88f5
22 changed files with 502 additions and 205 deletions

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,6 +30,10 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
@@ -77,14 +81,26 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
},
},
})
validate(
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
@Body('gateId') gateId?: string,
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
) {
return this.service.validate(ref, validatorId, gateId);
return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +122,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
@ApiOperation({
summary: 'Batch import offline validations',
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
})
@ApiBody({
schema: {
type: 'object',
properties: {
validations: {
type: 'array',
items: {
type: 'object',
required: ['bookingRef', 'validatorId', 'validatedAt'],
properties: {
bookingRef: { type: 'string' },
validatorId: { type: 'string' },
gateId: { type: 'string' },
validatedAt: { type: 'string', format: 'date-time' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
},
},
},
},
},
})
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}

View File

@@ -7,6 +7,7 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: 'OUTBOUND' | 'RETURN';
}
@Injectable()
@@ -49,6 +50,10 @@ export class TicketsService {
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,
@@ -213,22 +218,67 @@ export class TicketsService {
};
}
async validate(bookingRef: string, validatorId: string, gateId?: string) {
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
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');
if (ticket.validatedAt) {
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
// For one-way bookings use the original single-validation guard
if (!isRoundTrip) {
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' },
});
throw new BadRequestException('Ticket already validated');
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
});
throw new BadRequestException('Ticket already validated');
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
// Round-trip: track which leg is being boarded
const resolvedLeg = leg ?? 'OUTBOUND';
const now = new Date();
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
});
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
// Stamp the ticket's first validation
if (!ticket.validatedAt) {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
}
} else {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
});
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
}
// Derive the new composite status
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'; // return pending/no-show
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, gateId, status: 'APPROVED' }
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
@@ -256,6 +306,8 @@ export class TicketsService {
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,
}));
}
@@ -265,11 +317,13 @@ export class TicketsService {
const processedRefs = new Set<string>();
for (const v of validations) {
if (processedRefs.has(v.bookingRef)) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(v.bookingRef);
processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +340,21 @@ export class TicketsService {
continue;
}
if (ticket.validatedAt) {
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
results.duplicate++;
continue;
}
// For round-trip, check per-leg duplication
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const alreadyUsed =
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
if (alreadyUsed) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +365,24 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
},
} as any,
});
// update returnLegStatus for round-trip offline validations
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
const bookingData: Record<string, any> =
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = offlineLeg === '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 });
}
results.success++;
} catch (err) {
results.failed++;