mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Refactored the whole app based on the requirements shared
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -9,6 +9,38 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
@Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
|
||||
@Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); }
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({ summary: 'Get ticket by booking reference' })
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@ApiOperation({ summary: 'Validate ticket at gate (staff)' })
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId);
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
return this.service.getValidationLogs(ticketId);
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('tripId') tripId: string) {
|
||||
return this.service.exportOfflineData(tripId);
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
describe('TicketsService - Offline Validation', () => {
|
||||
let service: TicketsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrisma = {
|
||||
booking: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
ticket: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
gateValidationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TicketsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TicketsService>(TicketsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('exportOfflineData', () => {
|
||||
it('should export tickets for offline validation', async () => {
|
||||
const mockBookings = [
|
||||
{
|
||||
bookingRef: 'ABC123',
|
||||
ticket: { id: 'ticket-1', qrPayload: 'qr-data', validatedAt: null },
|
||||
seats: [{ passengerName: 'John Doe', seat: { label: '1A', coach: { label: 'A' } } }],
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.booking.findMany.mockResolvedValue(mockBookings);
|
||||
|
||||
const result = await service.exportOfflineData('trip-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].bookingRef).toBe('ABC123');
|
||||
expect(result[0].passengerName).toBe('John Doe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOfflineBatch', () => {
|
||||
it('should process batch validations successfully', async () => {
|
||||
const validations = [
|
||||
{
|
||||
bookingRef: 'ABC123',
|
||||
validatorId: 'validator-1',
|
||||
gateId: 'gate-1',
|
||||
validatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
|
||||
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
|
||||
mockPrisma.ticket.update.mockResolvedValue({});
|
||||
mockPrisma.gateValidationLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.validateOfflineBatch(validations);
|
||||
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.duplicate).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect duplicate validations', async () => {
|
||||
const validations = [
|
||||
{
|
||||
bookingRef: 'ABC123',
|
||||
validatorId: 'validator-1',
|
||||
validatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
bookingRef: 'ABC123',
|
||||
validatorId: 'validator-1',
|
||||
validatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
|
||||
mockPrisma.ticket.findUnique.mockResolvedValue({ id: 'ticket-1', validatedAt: null });
|
||||
mockPrisma.ticket.update.mockResolvedValue({});
|
||||
mockPrisma.gateValidationLog.create.mockResolvedValue({});
|
||||
|
||||
const result = await service.validateOfflineBatch(validations);
|
||||
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.duplicate).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle already validated tickets', async () => {
|
||||
const validations = [
|
||||
{
|
||||
bookingRef: 'ABC123',
|
||||
validatorId: 'validator-1',
|
||||
validatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({ id: 'booking-1' });
|
||||
mockPrisma.ticket.findUnique.mockResolvedValue({
|
||||
id: 'ticket-1',
|
||||
validatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.validateOfflineBatch(validations);
|
||||
|
||||
expect(result.duplicate).toBe(1);
|
||||
expect(result.success).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
interface OfflineValidation {
|
||||
bookingRef: string;
|
||||
validatorId: string;
|
||||
gateId?: string;
|
||||
validatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -13,7 +20,12 @@ export class TicketsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
|
||||
return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } });
|
||||
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
return this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
|
||||
});
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
@@ -29,15 +41,110 @@ export class TicketsService {
|
||||
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
|
||||
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string) {
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string) {
|
||||
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) throw new BadRequestException('Ticket already validated');
|
||||
return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
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: 'APPROVED' }
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
}
|
||||
|
||||
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: { tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
seats: { include: { seat: { include: { coach: 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.label,
|
||||
coachLabel: b.seats[0]?.seat.coach.label,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
validatedAt: b.ticket?.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) {
|
||||
if (processedRefs.has(v.bookingRef)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(v.bookingRef);
|
||||
|
||||
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) {
|
||||
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,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
},
|
||||
});
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user