import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query, 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 { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags, } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, } from '../../common/freight-permission.util'; import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; import { ContractsService } from './contracts.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { SignaturesService } from '../signatures/signatures.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { ApproveStepDto, RejectContractDto, RequestChangesDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; import { AdviseDutyDto, AssignRiskDto, AssignStationDto, CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; @ApiTags('contracts') @Controller('contracts') @ApiBearerAuth() export class ContractsController { constructor( private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, private readonly clearanceService: ContractClearanceService, private readonly contractBookingService: ContractBookingService, private readonly milestoneService: ClearanceMilestoneService, private readonly glOperationsService: GlOperationsService, private readonly signaturesService: SignaturesService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' }) @ApiBody({ type: CreateContractDto }) async create( @Body() dto: CreateContractDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); } return this.contractsService.create(dto, files ?? [], user?.id); } @Get() @ApiOperation({ summary: 'List contracts (paginated)' }) async findAll( @Query() filter: FilterContractDto, @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { return this.contractsService.findAll(filter); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); const companyId = await this.contractsService.resolveCustomerCompanyId(userId); 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, }, }; } return this.contractsService.findAll(filter, companyId); } @Get('my') @ApiOperation({ summary: "List the current customer's contracts" }) async findMy( @CurrentUser() user: AuthUserPayload, @Query() filter: FilterContractDto, ) { const userId = resolveAuthUserId(user); const companyId = await this.contractsService.resolveCustomerCompanyId(userId); 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, }, }; } return this.contractsService.findAll(filter, companyId); } @Get('list-summary') @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOkResponse({ type: ContractListSummaryDto }) findListSummary(@Query() filter: FilterContractDto) { return this.contractsService.getListSummary(filter); } @Get('clearance/queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' }) clearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.queue(filter); } @Get(':id') @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) async findOne( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return contract; } @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Update contract', description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', }) @ApiBody({ type: UpdateContractDto }) update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.contractsService.update(id, dto, files ?? []); } @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT contract' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.contractsService.remove(id); } @Post(':id/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' }) uploadDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.contractsService.uploadDocuments(id, files ?? []); } @Post(':id/generate-price') @ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') @ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } @Post(':id/confirm-submit') @ApiOperation({ summary: 'Confirm submit after a price change' }) confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } @Post(':id/staff/accept') @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, ); } @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), ); } @Post(':id/staff/reject') @BookingStaff(FREIGHT_PERMS.contracts.reject) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); } @Post(':id/approval-steps/:stepId/approve') @BookingStaff([ FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) @ApiOperation({ summary: 'Approve one approval step in sequence' }) approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), dto.requiredRole, user, ); } @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.contracts.generateContract) @ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' }) generateContract(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.generateContract(id); } @Get(':id/contract/view') @ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' }) async getContractView( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = await this.transitionService.getContractDocumentView(id); // The signer's reusable saved signature (if any) so the sign UI can offer // "Approve & sign" with the stored image instead of forcing a fresh draw. const signerId = resolveAuthUserId(user); const savedSignature = signerId ? await this.signaturesService.getForUser(signerId) : null; return { contractId: view.bookingId, reference: view.reference, status: view.status, templateKey: view.templateKey, title: view.template.title, html, view, canSignCustomer: view.canSignCustomer, canSignStaff: view.canSignStaff, hasContractDocument: view.hasContractDocument, signatures, savedSignature, }; } @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.sign(id, dto, { signerUserId: user?.id ?? user?.sub, }); } @Post(':id/renew') @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) renew( @Param('id', ParseUUIDPipe) id: string, @Body() _dto: RenewContractDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.renew(id, user?.id ?? user?.sub); } // ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── @Get(':id/clearance') @ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' }) getClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.getClearanceView(id); } @Post(':id/clearance/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) uploadClearanceDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.clearanceService.uploadDocuments(id, files ?? []); } @Post(':id/clearance/review') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReviewClearanceDocumentDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.reviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); } @Post(':id/clearance/output-documents') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' }) uploadOutputDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.clearanceService.uploadOutputDocuments(id, files ?? []); } @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', }) opsClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.opsQueue(filter); } @Post(':id/clearance/ops-review') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations reviews a customer self-clearance document (Approve | Query)', }) opsReviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReviewClearanceDocumentDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.opsReviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); } @Post(':id/clearance/ops-finalize') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations finalizes self-clearance → customer may create the booking', }) opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.opsFinalize(id); } @Get('clearance/history') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) @ApiOperation({ summary: 'GL ET clearance history: contracts that completed Path B clearance' }) clearanceHistory(@Query() filter: FilterContractDto) { return this.clearanceService.history(filter); } @Get('clearance/ops-history') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations clearance history: contracts that completed Path A self-clearance' }) opsClearanceHistory(@Query() filter: FilterContractDto) { return this.clearanceService.opsHistory(filter); } // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @Post(':id/bookings') @ApiOperation({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) createBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, @CurrentUser() user: AuthUserPayload, ) { // The service decides the execution path from the contract: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. return this.contractBookingService.createUnderContract( id, dto, { id: user?.id ?? user?.sub }, user, ); } @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', }) async capacity(@Param('id', ParseUUIDPipe) id: string) { const contract = await this.contractsService.findById(id); return this.contractBookingService.computeCapacity(contract); } // ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── @Get(':id/milestones') @ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' }) listContractMilestones(@Param('id', ParseUUIDPipe) id: string) { return this.milestoneService.listForContract(id); } @Get('bookings/:bookingId/milestones') @ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' }) listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.milestoneService.listForBooking(bookingId); } @Post('bookings/:bookingId/milestones/:code/complete') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' }) completeBookingMilestone( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('code') code: string, @Body() body: { note?: string }, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.completeForBooking( bookingId, code, user?.id ?? user?.sub, body?.note, ); } @Post(':id/milestones/:code/complete') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' }) completeContractMilestone( @Param('id', ParseUUIDPipe) id: string, @Param('code') code: string, @Body() body: CompleteMilestoneDto, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.completeForContract( id, code, resolveAuthUserId(user), body?.note, ); } // ── GL operational actions on a booking (doc §11–§13) ────────────────────── @Post('bookings/:bookingId/risk') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' }) assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.assignRisk( bookingId, dto.riskLevel, resolveAuthUserId(user), dto.note, ); } @Post('bookings/:bookingId/duty') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' }) adviseDuty( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AdviseDutyDto, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.adviseDuty( bookingId, { amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial }, resolveAuthUserId(user), dto.note, ); } @Post('bookings/:bookingId/station-assign') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' }) assignStation( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignStationDto, ) { return this.glOperationsService.assignStation(bookingId, { stationYardId: dto.stationYardId, staffId: dto.staffId, }); } @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)', }) uploadGlDocuments( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.glOperationsService.uploadDocuments(bookingId, files ?? []); } @Post('bookings/:bookingId/duty-slip') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) uploadDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); } @Get('bookings/:bookingId/incidents') @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.glOperationsService.listIncidents(bookingId); } @Post('bookings/:bookingId/incidents') @BookingStaff(FREIGHT_PERMS.bookings.operations) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' }) reportIncident( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: ReportIncidentDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: AuthUserPayload, ) { return this.glOperationsService.reportIncident( bookingId, { incidentType: dto.incidentType, description: dto.description, files: files ?? [], }, resolveAuthUserId(user), ); } }