import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res, UnauthorizedException, 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 { AcceptIntakeDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, RejectStepDto, RequestChangesDto, ReviewDocumentDto, RequestOperationDto, OperationReviewDto, 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)' }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, ) { // Staff (backoffice) see every booking. Customers (portal) are always // force-scoped to their own company, regardless of any companyId they pass. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { return this.bookingsService.findAll(filter); } // Global Logistics has clearance:view but NOT bookings:view — it is scoped // to the customs document-clearance queue only and never sees the general // booking-request list. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). if (!companyId) { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; return { items: [], total: 0, meta: { page, pageSize, total: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false, }, }; } // Company-wide by default; the optional filter.companyProfileId (per-page // service filter) narrows within the company. The company guard always // applies, so a customer can only ever see their own company's bookings. return this.bookingsService.findAll(filter, companyId); } @Get('by-company/:companyId/customer-view') @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) findByCompanyCustomerView( @Param('companyId', ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } @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('my') @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: 'Bookings owned by the authenticated user\'s company that are payable ' + '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', }) findMyPayable( @CurrentUser() user: AuthUserPayload, @Query() filter: FilterBookingDto, ) { return this.bookingsService.findMyPayable(resolveAuthUserId(user), 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, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); // Staff see any booking; customers only their own company's. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.transitionService.enrichBookingResponse(booking); } @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) async findOne( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); // Staff see any booking; Global Logistics (clearance:view) may inspect any // booking for the clearance gate; customers only their own company's. if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.transitionService.enrichBookingResponse(booking); } @Get(':id/tracking') @ApiOperation({ summary: 'Shipment tracking timeline for a booking', description: "Returns the booking's consignment (once dispatched) and its ordered " + 'tracking events. Scoped to the customer\'s own company.', }) async findTracking( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); // Staff see any booking; customers only their own company's. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.bookingsService.getBookingTracking(id); } @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/reject') @ApiOperation({ summary: 'Customer reject price estimate', description: 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', }) async reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } // ── Document clearance (post counter-sign) ──────────────────────────────── @Get(':id/clearance') @ApiOperation({ summary: 'Document-clearance grid (required docs + upload + GL review status)', }) getClearance(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } @Post(':id/clearance/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)', }) async submitClearanceDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( id, files ?? [], ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/proceed') @ApiOperation({ summary: 'Customer requests operation with a schedule day ' + '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', }) async proceedToOperation( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/operation/review') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', }) async reviewOperationRequest( @Param('id', ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.reviewOperationRequest( id, dto.decision, resolveAuthUserId(user), { note: dto.note }, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/review') @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) async reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.reviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/output-documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) async uploadClearanceOutput( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( id, files ?? [], ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', }) async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } @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 → set contract validity window + start approval chain', }) async acceptIntake( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.acceptIntake( id, resolveAuthUserId(user), dto.validityDays, ); 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, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } @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); } }