import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags, } from '@nestjs/swagger'; import type { Response } from 'express'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { ApproveStepDto, CancelBookingDto, RejectStepDto, RequestChangesDto, StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; @ApiTags('bookings') @Controller('bookings') @ApiBearerAuth() export class BookingsController { constructor( private readonly bookingsService: BookingsService, private readonly bookingReferenceDataService: BookingReferenceDataService, private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } const result = await this.bookingsService.create(dto, files ?? [], user?.id); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); const submitted = await this.bookingsService.findById(result.booking.id); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. return result; } } return result; } @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Update booking', description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', }) @ApiBody({ type: UpdateBookingDto }) update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.bookingsService.update(id, dto, files ?? []); } @Get() @ApiOperation({ summary: 'List freight bookings (paginated)' }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } @Get('list-summary') @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } @Get('queues/:queue') @ApiOperation({ summary: 'List bookings for a dashboard queue', description: 'Queues: intake, approval, signatures, marketing, finance', }) findQueue( @Param('queue') queue: string, @Query() filter: FilterBookingDto, @Query('excludeBulk') excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { excludeBulk: excludeBulk === 'true', }); } @Get('reference-data') @ApiOperation({ summary: 'Booking form catalog' }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } @Get('by-reference/:reference') @ApiOperation({ summary: 'Get booking by reference' }) async findByReference(@Param('reference') reference: string) { const booking = await this.bookingsService.findByReference(reference); return this.transitionService.enrichBookingResponse(booking); } @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) async findOne(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.bookingsService.findById(id); return this.transitionService.enrichBookingResponse(booking); } @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } @Post(':id/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) async uploadDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/generate-price') @ApiOperation({ summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', description: 'Computes and stores a price preview on the booking. Does not create rate snapshots.', }) @ApiOkResponse({ type: GeneratePriceResponseDto }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') @ApiOperation({ summary: 'Customer submit booking', description: 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', }) @ApiOkResponse({ type: SubmitBookingResponseDto }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } @Post(':id/confirm-submit') @ApiOperation({ summary: 'Confirm submit after price change', description: 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', }) @ApiOkResponse({ type: SubmitBookingResponseDto }) confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: 'Staff return booking for customer updates' }) async requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/staff/accept') @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) async acceptIntake( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.acceptIntake( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/staff/reject') @BookingStaff(FREIGHT_PERMS.bookings.reject) @ApiOperation({ summary: 'Staff final reject' }) async staffReject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.staffReject( id, dto.reason, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/government-expedite') @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) async governmentExpedite( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/approval-steps/:stepId/approve') @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) @ApiOperation({ summary: 'Approve one approval step in sequence' }) async approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), dto.requiredRole, user, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/approval-steps/:stepId/reject') @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) @ApiOperation({ summary: 'Reject at approval step' }) async rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.rejectStep( id, stepId, resolveAuthUserId(user), dto.reason, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.bookings.generateContract) @ApiOperation({ summary: 'Generate contract PDF from template' }) async generateContract(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } @Get(':id/contract/view') @ApiOkResponse({ type: ContractViewDto }) @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) getContractView(@Param('id', ParseUUIDPipe) id: string) { return this.contractService.getContractView(id); } @Get(':id/contract/document') @ApiOperation({ summary: 'Download contract PDF' }) async downloadContractDocument( @Param('id', ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); res.setHeader( 'Content-Disposition', `attachment; filename="${record.name}"`, ); stream.pipe(res); } @Get(':id/contract') @ApiOperation({ summary: 'Download contract file (alias)' }) async downloadContract( @Param('id', ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) async signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { const userId = req.user?.id ?? req.user?.sub; const booking = await this.contractService.signContract(id, dto, { signerUserId: userId, ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Get(':id/contract/signatures') @ApiOperation({ summary: 'List contract signatures' }) getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } @Get(':id/summary') @ApiOperation({ summary: 'Contract summary string for dashboard' }) getSummary(@Param('id', ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } @Post(':id/customer/sign') @ApiOperation({ summary: 'Customer digital signature (deprecated — use POST contract/sign)', }) async customerSign( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/marketing/approve') @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', }) async marketingApprove( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, role: 'STAFF', }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/operations/start-transit') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Mark in transit' }) async startTransit(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/operations/complete') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Mark completed' }) async complete(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/cancel') @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: 'Cancel booking' }) async cancel( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/consolidation') @ApiOperation({ summary: 'Request freight consolidation' }) requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } @Delete(':id/consolidation') @ApiOperation({ summary: 'Remove consolidation pairing' }) removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } @Get(':id/consolidation') @ApiOperation({ summary: 'Get consolidation details' }) getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } }