import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @Controller('tickets') export class TicketsController { constructor(private service: TicketsService) {} @Post('smart-assign/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Smart seat assignment + ticket generation', description: 'Keeps original seats if still free, auto-reassigns to an available seat of the same coach type if taken, ' + 'or throws 409 if the schedule is fully booked in that class.', }) smartAssignAndGenerate(@Param('bookingId') bookingId: string) { return this.service.smartAssignAndGenerate(bookingId); } @Post('generate/:bookingId') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' }) generateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); } @Patch('update-seats/:bookingId') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update ticket seats before final confirmation', description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.' }) updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) { return this.service.updateSeats(bookingId, body.seatIds); } @Get() @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'originStationId', required: false }) @ApiQuery({ name: 'destinationStationId', required: false }) @ApiQuery({ name: 'arrivalDate', required: false }) @ApiQuery({ name: 'departureDate', 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( @Query('search') search?: string, @Query('status') status?: string, @Query('originStationId') originStationId?: string, @Query('destinationStationId') destinationStationId?: string, @Query('arrivalDate') arrivalDate?: string, @Query('departureDate') departureDate?: string, @Query('dateFrom') dateFrom?: string, @Query('dateTo') dateTo?: string, @Query('coachId') coachId?: string, @Query('skip') skip?: string, @Query('take') take?: string, ) { return this.service.listTickets({ search, status, originStationId, destinationStationId, arrivalDate, departureDate, dateFrom, dateTo, coachId, skip: skip ? parseInt(skip) : 0, take: take ? parseInt(take) : 50, }); } @Get('by-order/:merchantOrderId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get ticket by merchant order ID', description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.' }) getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) { return this.service.getByMerchantOrderId(merchantOrderId); } @Get(':bookingRef') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } @Post('scan-board/:qrCodeOrRef') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-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') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', 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', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'], description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2', }, }, }, }) validate( @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string, @Body('gateId') gateId?: string, @Body('leg') leg?: string, ) { return this.service.validate(ref, validatorId, gateId, leg); } @Get(':ticketId/validation-logs') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get validation logs for ticket' }) getValidationLogs(@Param('ticketId') ticketId: string) { return this.service.getValidationLogs(ticketId); } @Get('offline/export') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Export tickets for offline validation' }) exportOfflineData(@Query('scheduleId') scheduleId: string) { return this.service.exportOfflineData(scheduleId); } @Post('validate/offline') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @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', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] }, }, }, }, }, }, }) validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } @Delete(':id') @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete ticket (admin only)', description: 'Permanently deletes a ticket record and removes associated seat blocks' }) delete(@Param('id') id: string) { return this.service.delete(id); } @Patch(':id/restore') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) restore(@Param('id') id: string) { return this.service.restore(id); } }