import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, UploadedFiles, UseInterceptors, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags, } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; import { documentUploadMulterOptions } from "../../common/document-upload.options"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto"; import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto"; import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto"; import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto"; import { TransitAssignmentsService } from "./transit-assignments.service"; /** * Transit assignments — one transit agent's work on one booking. * * Distinct from the transit-assignee handshake under * `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle * a shipment's customs. This is the work record that follows: status, timings * and documents. */ @ApiTags("transit-assignments") @Controller("transit-assignments") @ApiBearerAuth() export class TransitAssignmentsController { constructor( private readonly transitAssignmentsService: TransitAssignmentsService, ) {} // ── Portal — the signed-in transit agent's own work ─────────────────────── // Declared first so the literal `my` segment is matched before `:id`. // Every route resolves the agent from the session; none accepts an agent id. @Get("my/stats") @PortalCustomer() @ApiOperation({ summary: "Dashboard figures for the signed-in transit agent's own work", }) myStats(@CurrentUser() user: TCurrentUser) { return this.transitAssignmentsService.myStats(user.id); } @Get("my") @PortalCustomer() @ApiOperation({ summary: "The signed-in transit agent's assigned bookings (paginated, filterable)", }) findMine( @CurrentUser() user: TCurrentUser, @Query() query: MyAssignmentsQueryDto, ) { return this.transitAssignmentsService.findMine(user.id, query); } @Get("my/:id") @PortalCustomer() @ApiOperation({ summary: "One of my assignments, with its documents" }) findMineById( @CurrentUser() user: TCurrentUser, @Param("id", ParseUUIDPipe) id: string, ) { return this.transitAssignmentsService.findMineById(user.id, id); } @Post("my/:id/files") @PortalCustomer() @ApiConsumes("multipart/form-data") @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) @ApiOperation({ summary: "Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.", }) uploadMyFiles( @CurrentUser() user: TCurrentUser, @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], // One `titles` part per file, in the same order. A single-file upload posts // one part, which multipart parsing hands back as a bare string rather than // an array — normalised here so the service always sees a positional list. @Body("titles") titles?: string | string[], ) { return this.transitAssignmentsService.uploadMyFiles( user.id, id, files, { userId: user.id, name: user.name?.en ?? undefined }, titles === undefined ? undefined : ([] as string[]).concat(titles), ); } @Delete("my/:id/files/:fileId") @PortalCustomer() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: "Remove a document from my assignment" }) removeMyFile( @CurrentUser() user: TCurrentUser, @Param("id", ParseUUIDPipe) id: string, @Param("fileId", ParseUUIDPipe) fileId: string, ) { return this.transitAssignmentsService.removeMyFile(user.id, id, fileId); } @Post("my/:id/submit") @PortalCustomer() @ApiOperation({ summary: "Save progress, or finish the assignment (which locks its documents)", }) submitMine( @CurrentUser() user: TCurrentUser, @Param("id", ParseUUIDPipe) id: string, @Body() dto: SubmitTransitAssignmentDto, ) { return this.transitAssignmentsService.submitMine(user.id, id, dto); } // ── Backoffice ──────────────────────────────────────────────────────────── @Get() @BookingStaff(FREIGHT_PERMS.transitAssignments.view) @ApiOperation({ summary: "List transit assignments (paginated, filterable by booking / agent / status)", }) findAll(@Query() query: TransitAssignmentQueryDto) { return this.transitAssignmentsService.findAll(query); } /** * Declared before `:id` — Nest matches routes in order, so a literal segment * registered after a parameter would be swallowed by it. */ @Get("by-agent/:transitAgentId") @BookingStaff(FREIGHT_PERMS.transitAssignments.view) @ApiOperation({ summary: "Every assignment handed to one transit agent" }) findByTransitAgent( @Param("transitAgentId", ParseUUIDPipe) transitAgentId: string, ) { return this.transitAssignmentsService.findByTransitAgent(transitAgentId); } @Get("by-booking/:bookingId") @BookingStaff(FREIGHT_PERMS.transitAssignments.view) @ApiOperation({ summary: "Every transit agent assigned to one booking" }) findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { return this.transitAssignmentsService.findByBooking(bookingId); } @Get(":id") @BookingStaff(FREIGHT_PERMS.transitAssignments.view) @ApiOperation({ summary: "One assignment, with its attached documents and computed duration", }) findOne(@Param("id", ParseUUIDPipe) id: string) { return this.transitAssignmentsService.findById(id); } @Post() @BookingStaff(FREIGHT_PERMS.transitAssignments.create) @ApiOperation({ summary: "Assign a transit agent to a booking" }) create( @Body() dto: CreateTransitAssignmentDto, @CurrentUser() user: TCurrentUser, ) { return this.transitAssignmentsService.create(dto, user?.id); } @Patch(":id") @BookingStaff(FREIGHT_PERMS.transitAssignments.update) @ApiOperation({ summary: "Update status or note — status changes stamp the start/finish clocks", }) update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAssignmentDto, ) { return this.transitAssignmentsService.update(id, dto); } @Delete(":id") @BookingStaff(FREIGHT_PERMS.transitAssignments.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: "Soft-delete an assignment" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.transitAssignmentsService.remove(id); } // ── Documents ───────────────────────────────────────────────────────────── @Get(":id/files") @BookingStaff(FREIGHT_PERMS.transitAssignments.view) @ApiOperation({ summary: "An assignment's uploaded documents" }) listFiles(@Param("id", ParseUUIDPipe) id: string) { return this.transitAssignmentsService.listFiles(id); } @Post(":id/files") @BookingStaff(FREIGHT_PERMS.transitAssignments.update) @ApiConsumes("multipart/form-data") @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) @ApiOperation({ summary: "Upload one or more documents; re-uploading adds a version, it does not overwrite", }) uploadFiles( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, @Body("titles") titles?: string | string[], ) { return this.transitAssignmentsService.uploadFiles( id, files, { userId: user?.id, name: user?.name?.en ?? undefined }, titles === undefined ? undefined : ([] as string[]).concat(titles), ); } @Delete(":id/files/:fileId") @BookingStaff(FREIGHT_PERMS.transitAssignments.update) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: "Remove one document from an assignment" }) removeFile( @Param("id", ParseUUIDPipe) id: string, @Param("fileId", ParseUUIDPipe) fileId: string, ) { return this.transitAssignmentsService.removeFile(id, fileId); } }