mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat: add Transit Clearance Action Panel for managing delivery and release orders
- 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.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -783,6 +784,64 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
/**
|
||||
* Scope a clearance READ that a transit agent may be making.
|
||||
*
|
||||
* Transit agents are portal accounts holding no permission and belonging to
|
||||
* no company, so the audience guards admit them but the usual company-based
|
||||
* ownership check would 404 every booking. This narrows them to the shipments
|
||||
* assigned to them and leaves every other caller — staff and owning customers
|
||||
* — on the path they already had. Purely widening: nothing that passed before
|
||||
* starts failing here.
|
||||
*/
|
||||
private async assertTransitAgentScope(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
const userId = user?.id;
|
||||
if (!userId) return;
|
||||
if (!(await this.bookingsService.isTransitAgent(userId))) return;
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
|
||||
) {
|
||||
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
|
||||
// customer-ownership failure mode.
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a formerly staff-only clearance route that is now MixedAudience.
|
||||
*
|
||||
* Staff still pass on their permission. A portal caller must be a transit
|
||||
* agent assigned to THIS booking — an ordinary customer is rejected, because
|
||||
* relaxing the guard must not hand the whole customer base a route that was
|
||||
* previously staff-only.
|
||||
*
|
||||
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
|
||||
* assigned agent files them in the desk's place, and the assignment is the
|
||||
* only thing standing between a portal token and the customs record.
|
||||
*/
|
||||
private async assertPortalClearanceAccess(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
@@ -1128,6 +1187,9 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/clearance")
|
||||
// A transit agent is a portal account, so MixedAudience admits them without a
|
||||
// permission; `assertTransitAgentScope` below narrows them to the shipments
|
||||
// actually assigned to them.
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -1136,7 +1198,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertTransitAgentScope(id, user);
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@@ -1278,8 +1344,11 @@ export class BookingsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Was staff-only. Opened to the transit agent assigned to the shipment, who
|
||||
// needs the clearance trail for the bookings they handle; every other portal
|
||||
// account is still rejected by the scope check below.
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@@ -1287,7 +1356,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearanceHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
@@ -1310,6 +1383,12 @@ export class BookingsController {
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
// The transit agent handling this shipment sees the same customer-facing
|
||||
// slice the customer does — charges actually sent, never the internal
|
||||
// draft/billing view `list()` returns.
|
||||
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -1777,8 +1856,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Djibouti-desk write, also filed by the transit agent assigned to this
|
||||
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
|
||||
@Post(":id/clearance/delivery-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDeliveryOrder(
|
||||
@@ -1788,6 +1869,7 @@ export class BookingsController {
|
||||
@Body("doCollectedDate") doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1798,7 +1880,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/release-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingReleaseOrder(
|
||||
@@ -1807,6 +1889,7 @@ export class BookingsController {
|
||||
@Body("vesselDepartureDate") vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1821,12 +1904,13 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/ro-amendment")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
|
||||
@@ -1982,6 +1982,65 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `userId` is a transit agent currently assigned to this booking.
|
||||
*
|
||||
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
|
||||
* assertion guards ~29 call sites, including wagon cancellations, rebooking
|
||||
* and customer-truck writes. A transit agent must reach the clearance READS
|
||||
* for the shipments they handle and nothing else, so the two ownership rules
|
||||
* stay separate and each caller opts in explicitly.
|
||||
*
|
||||
* Queried directly rather than through TransitAssignmentsService: that module
|
||||
* imports BookingsModule, so injecting it here would close an import cycle.
|
||||
*/
|
||||
/** Is this portal account a transit agent at all? */
|
||||
async isTransitAgent(userId: string | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_agents a
|
||||
WHERE a.user_id = $1 AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async isTransitAgentForBooking(
|
||||
userId: string | undefined,
|
||||
bookingId: string,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_assignments ta
|
||||
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
|
||||
WHERE a.user_id = $1
|
||||
AND ta.booking_id = $2
|
||||
AND ta.deleted_at IS NULL
|
||||
AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId, bookingId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a clearance READ on one booking for either audience a portal
|
||||
* account can be: the owning customer, or a transit agent assigned to it.
|
||||
*
|
||||
* Read-only by contract — every caller is a GET. Writes keep using
|
||||
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
|
||||
*/
|
||||
async assertCanReadBookingClearance(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
|
||||
await this.assertCustomerCanAccessBooking(userId, booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
|
||||
Reference in New Issue
Block a user