import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { LastMileRequestStatus } from '@edr/types'; import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto'; import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto'; import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; import { LastMileContractService } from './last-mile-contract.service'; import { LastMileRequestsService } from './last-mile-requests.service'; @ApiTags('last-mile-requests') @ApiBearerAuth() @Controller('last-mile-requests') export class LastMileRequestsController { constructor( private readonly requestsService: LastMileRequestsService, private readonly contractService: LastMileContractService, ) {} @Get() @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'List last-mile confirmation requests' }) findAll( @Query('status') status?: string, @Query('bookingId') bookingId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.requestsService.findAll({ status: status as LastMileRequestStatus | undefined, bookingId, page: page ? parseInt(page, 10) : undefined, pageSize: pageSize ? parseInt(pageSize, 10) : undefined, }); } @Get('free-truck-count') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' }) freeTruckCount() { return this.requestsService.freeTruckCount().then((count) => ({ count })); } // Customer-facing like :id — booking detail (portal + backoffice) lists the // booking's requests to link the stored LM contract. Ownership-checked in // the service for portal callers. @Get('by-booking/:bookingId') @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" }) findForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @CurrentUser() user: TCurrentUser, ) { return this.requestsService.findForBooking(bookingId, user?.id ?? null); } @Get(':id/price-estimate') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval', }) priceEstimate(@Param('id', ParseUUIDPipe) id: string) { return this.requestsService.priceEstimate(id); } // Customer-facing like :id/submit — the service ownership-checks against the // resolved company; staff may also open it (read-only view). @Get(':id/contract/view') @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' }) contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { return this.contractService.getContractView(id, user?.id ?? null); } @Get(':id/contract/document') @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Download the LM contract PDF (LM_.pdf)' }) async contractDocument( @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); } @Post(':id/contract/sign') @PortalCustomer() @ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignLastMileContractDto, @CurrentUser() user: TCurrentUser, ) { return this.contractService.sign(id, dto, user?.id ?? null); } // Customer-facing like :id/contract/view — the portal's confirm page opens // this straight from the departure notification link before the customer // has done anything else, so it can't be staff-only. Service ownership- // checks against the resolved company; staff may also open it. @Get(':id') @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { return this.requestsService.findById(id, user?.id ?? null); } // No @BookingStaff — the customer (portal) fills this, not backoffice staff. // TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service // still cross-checks the request's booking against the resolved company. @Post(':id/submit') @PortalCustomer() @ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" }) submit( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SubmitLastMileRequestDto, @CurrentUser() user: TCurrentUser, ) { return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate); } @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, @CurrentUser() user: TCurrentUser, ) { return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount); } @Post(':id/reject') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) @ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectLastMileRequestDto, @CurrentUser() user: TCurrentUser, ) { return this.requestsService.reject(id, user?.id ?? null, dto.reason); } }