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:
Marshal
2026-08-31 13:56:39 +00:00
parent 678c5d7d49
commit b5ad46f317
23 changed files with 3524 additions and 385 deletions

View File

@@ -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,

View File

@@ -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