Files
edr-platform/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
2026-07-22 11:15:30 +03:00

229 lines
8.6 KiB
TypeScript

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