mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 20:03:39 +00:00
- Implemented TransitClearanceActionPanel component for transit agents to handle DO/RO uploads and amendments. - Added file picker for uploading documents with validation for vessel arrival and collection dates. - Introduced modals for uploading T1 documents and requesting RO amendments. - Enhanced transit assignments service with new API endpoints for clearance history, GL exchange documents, and incident reports. - Updated types to include new document upload sources and statuses. - Created CSS styles for transit bookings table to improve layout and responsiveness. - Exported new TransitAgentBookingDetailPage for detailed booking views.
190 lines
5.9 KiB
TypeScript
190 lines
5.9 KiB
TypeScript
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<GlExchangeActor> {
|
|
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,
|
|
};
|
|
}
|
|
}
|