Refactored the whole app based on the requirements shared

This commit is contained in:
Stephanos A
2026-05-21 08:48:28 +03:00
parent 2dc3da9e74
commit 51bc906792
84 changed files with 6880 additions and 12659 deletions

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}