import { Body, Controller, Delete, Get, HttpCode, NotFoundException, Param, ParseUUIDPipe, Patch, Post, UploadedFile, 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 { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff, MixedAudience } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { BookingsService } from '../bookings/bookings.service'; import { GlExchangeService, type GlExchangeActor, type GlExchangeSide, } from './gl-exchange.service'; /** Either GL desk may read and post; ownership decides who may edit. */ const GL_EXCHANGE_PERMS = [ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]; /** Multipart bodies arrive as strings — "true"/"1" mean checked. */ const asBool = (raw: string | boolean | undefined): boolean => raw === true || raw === 'true' || raw === '1'; @ApiTags('gl-exchange') @ApiBearerAuth() @Controller('gl-exchange') export class GlExchangeController { constructor( private readonly exchangeService: GlExchangeService, private readonly bookingsService: BookingsService, ) {} // Read opened to the transit agent assigned to the shipment; the POST/PATCH/ // DELETE below stay staff-only, so an agent can read the desks' thread but // never post to it. @Get(':entityId') @MixedAudience(GL_EXCHANGE_PERMS) @ApiOperation({ summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', }) async list( @Param('entityId', ParseUUIDPipe) entityId: string, @CurrentUser() user: TCurrentUser, ) { const isStaff = GL_EXCHANGE_PERMS.some((p) => hasFreightPermission(user, p), ); if ( !isStaff && !(await this.bookingsService.isTransitAgentForBooking( user?.id, entityId, )) ) { throw new NotFoundException(`Entity ${entityId} not found`); } return this.exchangeService.list(entityId, resolveAuthUserId(user)); } // Open to the transit agent assigned to the shipment as well as both desks: // the officer on the ground is often the one holding the scan either desk // needs. Their post is attributed to the TRANSIT side, never to a desk. @Post(':entityId') @MixedAudience(GL_EXCHANGE_PERMS) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)', }) async upload( @Param('entityId', ParseUUIDPipe) entityId: string, @UploadedFile() file: Express.Multer.File | undefined, @Body('title') title: string, @Body('visibleToCustomer') visibleToCustomer: string | undefined, @CurrentUser() user: TCurrentUser, ) { const actor = await this.resolveActor(entityId, user); return this.exchangeService.upload( entityId, file, { title, visibleToCustomer: asBool(visibleToCustomer) }, actor, ); } @Patch('documents/:documentId') @BookingStaff(GL_EXCHANGE_PERMS) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Uploader edits a shared document (title, visibility, file)', }) update( @Param('documentId', ParseUUIDPipe) documentId: string, @UploadedFile() file: Express.Multer.File | undefined, @Body('title') title: string | undefined, @Body('visibleToCustomer') visibleToCustomer: string | undefined, @CurrentUser() user: TCurrentUser, ) { return this.exchangeService.update( documentId, { title, visibleToCustomer: visibleToCustomer == null ? undefined : asBool(visibleToCustomer), }, file, resolveAuthUserId(user), ); } @Delete('documents/:documentId') @BookingStaff(GL_EXCHANGE_PERMS) @HttpCode(204) @ApiOperation({ summary: 'Uploader removes a shared document' }) async remove( @Param('documentId', ParseUUIDPipe) documentId: string, @CurrentUser() user: TCurrentUser, ) { await this.exchangeService.remove(documentId, resolveAuthUserId(user)); } /** * Which desk is posting. A user holding only the Djibouti actions permission * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) * posts as Ethiopia. */ /** * Who is posting, for a route both desks and the assigned transit agent may * call. Staff keep the desk attribution below; a portal caller must be the * agent assigned to this shipment and posts as TRANSIT, so a document is * never credited to a desk that did not send it. */ private async resolveActor( entityId: string, user: TCurrentUser, ): Promise { const isStaff = GL_EXCHANGE_PERMS.some((p) => hasFreightPermission(user, p), ); if (isStaff) return this.actor(user); if ( !(await this.bookingsService.isTransitAgentForBooking( user?.id, entityId, )) ) { throw new NotFoundException(`Entity ${entityId} not found`); } return { userId: resolveAuthUserId(user), name: actorLabel(user) ?? null, side: 'TRANSIT', }; } private actor(user: TCurrentUser): GlExchangeActor { const side: GlExchangeSide = !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) ? 'DJ' : 'ET'; return { userId: resolveAuthUserId(user), name: actorLabel(user) ?? null, side, }; } }