Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
Marshal 0f7cac2b68 add booking request functionality for GENERAL customs contracts
- Create migration for booking_requests table with necessary fields and indexes.
- Implement BookingRequestRepository for database operations related to booking requests.
- Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests.
- Create DTOs for creating booking requests and reviewing them.
- Define BookingRequest entity to map to the booking_requests table.
- Add UI components for managing shipment requests, including detail and list pages.
- Implement OperationDatePicker component for selecting available shipment days.
2026-06-29 09:30:44 +00:00

714 lines
24 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Request,
Res,
UnauthorizedException,
UploadedFiles,
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 { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingsController {
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
) {}
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiBody({ type: CreateBookingDto })
async create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
return result;
}
}
return result;
}
@Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
// Company-wide by default; the optional filter.companyProfileId (per-page
// service filter) narrows within the company. The company guard always
// applies, so a customer can only ever see their own company's bookings.
return this.bookingsService.findAll(filter, companyId);
}
@Get('by-company/:companyId/customer-view')
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@Query() filter: FilterBookingDto,
) {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
})
findQueue(
@Param('queue') queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(
@Param('reference') reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({
summary: 'Customer submit booking',
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@ApiOperation({
summary: 'Confirm submit after price change',
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@ApiOperation({
summary: 'Customer reject price estimate',
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
// ── Document clearance (post counter-sign) ────────────────────────────────
@Get(':id/clearance')
@ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)',
})
getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)',
})
async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/proceed')
@ApiOperation({
summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
})
async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewOperationRequest(
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/review')
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/output-documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize')
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
})
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
dto.validityDays,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.staffReject(
id,
dto.reason,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/view')
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
})
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}