diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5bce9de95..2b3b855cb 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -49,6 +49,9 @@ MINIO_PORT=9000 MINIO_USE_SSL=false MINIO_ACCESS_KEY= MINIO_SECRET_KEY= +# Preset region so signed URLs are generated locally (no GetBucketLocation +# network call per sign). MinIO's default is us-east-1. +MINIO_REGION=us-east-1 # Redis REDIS_HOST=localhost diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..7525eda43 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -17,6 +17,10 @@ RUN pnpm dlx turbo prune "@edr/freight-api" --docker FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +# Puppeteer's bundled Chromium can't run on Alpine (glibc build). Skip its +# download here — the runner installs Alpine's system Chromium instead and we +# point PUPPETEER_EXECUTABLE_PATH at it. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile @@ -28,12 +32,26 @@ RUN pnpm turbo build --filter="@edr/freight-api..." FROM base AS deployer COPY --from=builder /app/ . +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts so puppeteer can render contract PDFs (HTML -> PDF). Without +# a working browser the PDF service falls back to an unstyled text-only PDF. +RUN apk add --no-cache \ + libc6-compat \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ttf-freefont \ + font-noto \ + font-noto-cjk ENV NODE_ENV=production +# Point puppeteer at the system Chromium and stop it trying to download its own. +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6dfe2b8b9..ccdab3379 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -62,16 +62,23 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count bookings created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNNN sequence already issued for `BK--…` references. + * Includes soft-deleted bookings so the next number clears references that + * still occupy the unique index. (A created-at count drifts below the issued + * sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('booking') - .where('booking.created_at >= :startDate', { startDate }) - .andWhere('booking.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a booking by reference with files and relations. */ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 1f2c1a09e..7a6169b7b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -9,6 +9,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; +import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; @@ -207,8 +208,8 @@ export class BookingsService { /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } private buildCustomerTruckFreightOrderHtml( @@ -593,7 +594,6 @@ export class BookingsService { } } - const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, @@ -688,7 +688,10 @@ export class BookingsService { // the customer clears it themselves and may name their broker. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - const booking = await this.bookingsRepository.create({ + // Explicit reference is caller-chosen (a collision is a real conflict); + // auto-generated references retry past a concurrent same-sequence insert. + const insertBooking = (reference: string) => + this.bookingsRepository.create({ reference, companyId, companyProfileId, @@ -743,7 +746,14 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', - }); + }); + + const booking = dto.reference + ? await insertBooking(dto.reference) + : await insertWithGeneratedReference( + () => this.generateReference(), + insertBooking, + ); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 7a3695768..0ae829529 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** Total rows — used to mint the next sequential reference. */ - async count(): Promise { - return this.repository.count(); + /** + * Highest NNNNNN sequence already issued for `SR-…` references (all-time — + * these are not year-scoped). Includes soft-deleted rows so a cancel/delete + * can't make the next number reuse an earlier one. A plain row count drifts + * below the issued sequence after any delete and hands out duplicates. + */ + async maxReferenceSequence(): Promise { + const row = await this.repository + .createQueryBuilder('request') + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('request.reference LIKE :prefix', { prefix: 'SR-%' }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 199276862..88a4ec725 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -192,8 +192,7 @@ export class BookingRequestService { } private async generateReference(): Promise { - const count = await this.repo.count(); - const seq = String(count + 1).padStart(6, '0'); - return `SR-${seq}`; + const seq = await this.repo.maxReferenceSequence(); + return `SR-${String(seq + 1).padStart(6, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 125cf5f3d..873daaf15 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -8,6 +8,7 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -110,7 +111,6 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, dto.contractRouteId); const warnings: string[] = []; - const reference = await this.generateReference(); const freightType = contract.freightType; // GENERAL + customs (Path B) runs per-booking clearance: the booking starts @@ -146,7 +146,11 @@ export class ContractBookingService { } // Denormalize route/direction/freight onto the booking for the scheduling engine. - const booking = await this.bookingsRepository.create({ + // Retry past a concurrent insert that grabbed the same BK sequence number. + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, @@ -180,7 +184,8 @@ export class ContractBookingService { lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, - } as never); + } as never), + ); // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { @@ -825,8 +830,7 @@ export class ContractBookingService { private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - const seq = String(count + 1).padStart(6, '0'); - return `BK-${year}-${seq}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9bb4b2de6..e31392666 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { Readable } from 'stream'; +import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; @@ -611,8 +612,11 @@ export class ContractTransitionService { async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); - const reference = await this.generateRenewalReference(); - const renewal = await this.contractsRepository.create({ + // Retry past a concurrent insert that grabbed the same CTR sequence number. + const renewal = await insertWithGeneratedReference( + () => this.generateRenewalReference(), + (reference) => + this.contractsRepository.create({ reference, companyId: source.companyId, companyProfileId: source.companyProfileId, @@ -640,7 +644,8 @@ export class ContractTransitionService { status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, - } as never); + } as never), + ); void userId; return this.contractsService.findById(renewal.id); @@ -648,7 +653,7 @@ export class ContractTransitionService { private async generateRenewalReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 34a958fd2..e53ba0e13 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count contracts created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNN sequence already issued for `CTR--…` references. + * Includes soft-deleted contracts — their references still occupy the unique + * index, so the next number must move past them. (A created-at count drifts + * below the issued sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('contract') - .where('contract.created_at >= :startDate', { startDate }) - .andWhere('contract.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a contract by ID with all child collections, service type, company and files. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 8d864aac7..73360f7f5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; @@ -57,8 +58,8 @@ export class ContractsService { /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } /** Whether a service type bundles customs clearance. */ @@ -144,8 +145,6 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); - const reference = dto.reference || (await this.generateReference()); - // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; if (!isGovernment && companyId) { @@ -177,7 +176,61 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - const contract = await this.contractsRepository.create({ + // An explicit reference is caller-chosen — a collision there is a real + // conflict and should surface. Auto-generated references retry past a + // concurrent insert that grabbed the same sequence number. + const contract = dto.reference + ? await this.insertContract(dto.reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }) + : await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.insertContract(reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }), + ); + + await this.persistRoutes(contract.id, dto.routes); + await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); + + if (files.length > 0) { + try { + await this.filesService.uploadMany(contract.id, 'contracts', files); + } catch { + warnings.push('File upload failed — contract was created without attached files.'); + } + } + + // Attach the company profile's onboarding / business-license documents to the + // contract by reference. The separate "Documents" intake step was removed — + // the profile documents are simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyProfileId); + + return { contract: await this.findById(contract.id), warnings }; + } + + /** Insert one DRAFT contract row with the given reference (no children). */ + private insertContract( + reference: string, + ctx: { + companyId: string | null | undefined; + companyProfileId: string | null; + isGovernment: boolean; + includesCustoms: boolean; + dto: CreateContractDto; + }, + ): Promise { + const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx; + return this.contractsRepository.create({ reference, companyId: companyId ?? null, companyProfileId, @@ -205,24 +258,6 @@ export class ContractsService { clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never); - - await this.persistRoutes(contract.id, dto.routes); - await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); - - if (files.length > 0) { - try { - await this.filesService.uploadMany(contract.id, 'contracts', files); - } catch { - warnings.push('File upload failed — contract was created without attached files.'); - } - } - - // Attach the company profile's onboarding / business-license documents to the - // contract by reference. The separate "Documents" intake step was removed — - // the profile documents are simply carried onto every contract automatically. - await this.attachProfileDocuments(contract.id, companyProfileId); - - return { contract: await this.findById(contract.id), warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts index 10482a325..a6decd1d0 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.config.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({ accessKey: process.env.MINIO_ACCESS_KEY || "", secretKey: process.env.MINIO_SECRET_KEY || "", bucket: process.env.MINIO_BUCKET || "fhc", + // Preset the region so presignedGetObject signs URLs locally. Without it the + // minio client fires a live GetBucketLocation request to the endpoint on every + // sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs + // API responses that reload a booking's files (e.g. staff accept). + region: process.env.MINIO_REGION || "us-east-1", })); diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 086da89f9..4b0804d62 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -29,6 +29,9 @@ export class MinioService { useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey, + // Presetting the region keeps presignedGetObject fully local — no live + // GetBucketLocation round-trip to the endpoint on each signed URL. + region: config.region, }); } @@ -108,8 +111,11 @@ export class MinioService { try { return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); } catch (error) { + // Signing a file URL must never break a booking/transition response — the + // caller only needs SOMETHING to link to. Degrade to the public object URL + // and log, rather than throwing (which would 500 an otherwise-good load). this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); - throw error; + return this.getPublicUrl(objectName); } } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f7b26af00..263e19af0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -474,7 +474,8 @@ const isClearanceItem = (item: SidebarItem): boolean => /** * Keep only items the user is permitted to see; drop now-empty sections. * - * Position-scoped visibility (super_admin bypasses all of this): + * Position-scoped visibility (super_admin sees everything): + * - Super Admin → sees all items (all permissions pass, all tabs visible) * - Ethiopian GL → sees ONLY the ET document-clearance page. * - Djibouti GL → sees ONLY the DJ clearance page. * - Everyone else → sees everything they have permission for, EXCEPT the two @@ -484,9 +485,11 @@ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const superAdmin = isSuperAdmin(user); - const etGl = !superAdmin && isEthiopianGl(user); - const djGl = !superAdmin && isDjiboutiGl(user); + // Superadmin sees every section and item — no permission filtering. + if (isSuperAdmin(user)) return sections; + + const etGl = isEthiopianGl(user); + const djGl = isDjiboutiGl(user); const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; @@ -497,8 +500,6 @@ const filterSidebarByPermission = ( }; const itemAllowed = (item: SidebarItem): boolean => { - if (superAdmin) return true; - // GL positions are locked to their single clearance page. if (etGl) return isEtClearanceItem(item); if (djGl) return isDjClearanceItem(item); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 6852590cd..75e901d10 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -122,7 +122,14 @@ function computeImportActiveStep( if (!clearance.gatepassGranted) return 8; if (!t1Uploaded && !clearance.t1?.closed) return 9; if (!clearance.t1?.closed) return 10; - if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 11; + // Risk is "assigned" when the booking milestone says so OR the clearance view + // already carries a riskLevel. The ET page derives its bookingMilestones from a + // separately-fetched booking id that can lag or mismatch the booking carrying + // the milestone — `clearance.riskLevel` is server truth and matches the badge. + const riskAssigned = + Boolean(clearance.riskLevel) || + isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED"); + if (!riskAssigned) return 11; // Additional duty round is optional — resolved once skipped or paid. const secondDutyResolved = clearance.secondDuty?.skipped || @@ -229,6 +236,13 @@ export function PhasedClearanceActionPanel({ const actionBookingId = clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null; const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0; + // Risk is assigned when the clearance view carries a riskLevel (server truth, + // drives the badge) OR the fetched booking milestone confirms it. Kept in sync + // with computeImportActiveStep so the stepper never freezes on a page whose + // bookingMilestones lag/mismatch the booking that holds the milestone. + const riskAssigned = + Boolean(clearance.riskLevel) || + isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED"); const activeStep = useMemo( () => isImport @@ -595,7 +609,7 @@ export function PhasedClearanceActionPanel({ label="Customs risk" description="GL Ethiopia assigns Green / Yellow / Red" icon={ - isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? ( + riskAssigned ? ( ) : ( @@ -606,7 +620,7 @@ export function PhasedClearanceActionPanel({ bookingId={actionBookingId} clearance={clearance} canAct={showEt && canEt} - done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")} + done={riskAssigned} onChanged={onChanged} /> @@ -620,7 +634,7 @@ export function PhasedClearanceActionPanel({ bookingId={actionBookingId} clearance={clearance} canAct={showEt && canEt} - riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")} + riskAssigned={riskAssigned} onChanged={onChanged} onViewFile={onViewFile} onDownloadFile={onDownloadFile} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 66abbdd72..2c3e6fa9d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -44,7 +44,7 @@ export default function ContractClearanceDetailPage() { const { id } = useParams<{ id: string }>(); const { view, viewer } = useFileViewer(); - const { data: contract } = useContractDetail(id); + const { data: contract, refetch: refetchContract } = useContractDetail(id); const { data: clearance, isLoading, @@ -250,6 +250,7 @@ export default function ContractClearanceDetailPage() { phasedCustoms={phasedCustoms} onChanged={() => { void refetch(); + void refetchContract(); void refetchBookingMilestones(); }} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 7dde7666b..6bd6994f6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -526,23 +526,13 @@ export default function NewContractPage({ }, ]; - // Routes — pure origin→destination lanes, no quantity. Route #1 is primary; - // extras only apply to GENERAL contracts. + // Route — a single origin→destination lane, general contracts included. const routes: Freight.CreateContractRouteInputDto[] = [ { originYardId: data.originYard, destinationYardId: data.destinationYard, sortOrder: 0, }, - ...(isGeneral - ? (data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r, i) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - sortOrder: i + 1, - })) - : []), ]; return { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index 8916ef67d..b2fc5168c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -52,13 +52,9 @@ export function contractToFormValues( const routes = [...(contract.routes ?? [])].sort( (a, b) => a.sortOrder - b.sortOrder, ); + // Contracts carry a single route now; older multi-route GENERAL contracts + // load only their primary route. const primaryRoute = routes[0]; - const extraRoutes = isGeneral - ? routes.slice(1).map((r) => ({ - originYard: r.originYardId, - destinationYard: r.destinationYardId, - })) - : []; const scope = contract.cargoScope ?? []; @@ -131,7 +127,6 @@ export function contractToFormValues( originYard: primaryRoute?.originYardId ?? "", destinationYard: primaryRoute?.destinationYardId ?? "", - extraRoutes, documents: {}, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index df0705e68..3cf78fd34 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{ { value: "general_contract", label: "General Contract", - description: "Ship multiple times over the validity window across routes.", + description: "Ship multiple times over the validity window on one route.", }, ]; @@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const; export type ContainerSize = (typeof CONTAINER_SIZES)[number]; // A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields -// can briefly emit "" / undefined / NaN (cleared or never-touched field); those -// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than -// letting them fail validation and silently block the Cargo & Route step. +// can briefly emit "" / undefined / NaN (cleared or never-touched field); +// coerce those to 0 so the superRefine below can flag them with a clear +// "greater than 0" message instead of a type error. const nonNegativeQuantityCap = z.preprocess( (v) => v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v, @@ -167,34 +167,23 @@ export const contractFormSchema = z // contract_cargo_scope row. enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]), // GENERAL only: per-size container quantity cap (total bookable over the - // validity window). Keyed by size; 0/undefined = uncapped. The NumberInput - // can momentarily hold "" / undefined (empty field) — coerce those to 0 so - // an untouched cap never blocks the step. + // validity window). Keyed by size; must be > 0 for every enabled size + // (enforced in the superRefine below). containerSizeCaps: z .record(z.string(), nonNegativeQuantityCap) .default({}), // Bulk scope: the cargo type path (group → commodity). cargoTypePath: z.array(z.string()).default([]), cargoFreeText: z.string().default(""), - // GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty- - // field coercion as the container caps above. + // GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine). bulkQuantityCap: nonNegativeQuantityCap.default(0), // Contract-level billing flags. isHazardous: z.boolean().default(false), isRefrigerated: z.boolean().default(false), - // ── Route ── + // ── Route ── (one route per contract — general contracts included) originYard: z.string().min(1, "Select an origin yard."), destinationYard: z.string().min(1, "Select a destination yard."), - // Additional routes for a GENERAL contract (route #1 is the primary above). - extraRoutes: z - .array( - z.object({ - originYard: z.string().default(""), - destinationYard: z.string().default(""), - }), - ) - .default([]), documents: z.record(z.string(), z.any()).default({}), notes: z.string().default(""), @@ -260,6 +249,29 @@ export const contractFormSchema = z }); } } + // GENERAL contracts must carry a real (> 0) quantity cap — an untouched + // NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the + // Cargo & Route step until the customer enters a quantity. + if (data.contractKind === "general_contract") { + if (data.cargoType === "container") { + for (const size of data.enabledContainerSizes) { + if (!(data.containerSizeCaps[size] > 0)) { + ctx.addIssue({ + code: "custom", + path: ["containerSizeCaps", size], + message: `Enter a ${size} quantity greater than 0.`, + }); + } + } + } + if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) { + ctx.addIssue({ + code: "custom", + path: ["bulkQuantityCap"], + message: "Enter a total quantity greater than 0.", + }); + } + } }); export type ContractFormValues = z.infer; @@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial = { originYard: "", destinationYard: "", - extraRoutes: [], documents: {}, notes: "", @@ -325,7 +336,6 @@ export const contractStepFields: Record< "isRefrigerated", "originYard", "destinationYard", - "extraRoutes", ], // Step 2 — Review & Submit. (The separate Documents step was removed — the // company profile documents are attached to the contract automatically.) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index eec7e896b..a4ec5aeb8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -132,19 +132,12 @@ export function Step1ContractType({ ); form.setValue("customsClearingAgent", contract.customsClearingAgent ?? ""); - // ── Route (primary + extras) ── + // ── Route (single route per contract) ── const routes = contract.routes ?? []; if (routes[0]) { form.setValue("originYard", routes[0].originYardId); form.setValue("destinationYard", routes[0].destinationYardId); } - form.setValue( - "extraRoutes", - routes.slice(1).map((r) => ({ - originYard: r.originYardId, - destinationYard: r.destinationYardId, - })), - ); // ── Cargo scope ── form.setValue( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 5f13d889a..d278c15ea 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -227,14 +227,14 @@ export function Step3CargoScope({ {/* GENERAL contract quantity cap (draw-down ceiling). */} {isGeneral && ( - Booking quantity cap (optional) + Booking quantity cap * Total quantity bookable across all shipments under this contract. - Customers / GL can book repeatedly until it is reached. Leave 0 for - unlimited. + Customers / GL can book repeatedly until it is reached. Must be + greater than 0. {cargoType === "container" ? ( - + {enabledSizes.length === 0 ? ( Select container sizes above to set their caps. @@ -245,13 +245,14 @@ export function Step3CargoScope({ key={size} name={`containerSizeCaps.${size}`} control={form.control} - render={({ field }) => ( + render={({ field, fieldState }) => ( field.onChange(Number(v) || 0)} + error={fieldState.error?.message} radius={10} styles={fieldStyles} /> @@ -264,13 +265,14 @@ export function Step3CargoScope({ ( + render={({ field, fieldState }) => ( field.onChange(Number(v) || 0)} + error={fieldState.error?.message} radius={10} styles={fieldStyles} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx index 1e9ead268..7b96676ef 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx @@ -1,12 +1,8 @@ import type { Freight } from "@edr/types"; -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { MapPin, Plus, Trash2 } from "lucide-react"; +import { Skeleton, Stack } from "@mantine/core"; +import { MapPin } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { ContractFormInputValues, type ContractFormValues } from "./schema"; import { getRouteDirection } from "./helpers"; import { SelectField, StepLabel } from "./shared"; @@ -29,7 +25,6 @@ export function Step4Route({ const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const operationType = form.watch("operationType"); - const isGeneralContract = form.watch("contractKind") === "general_contract"; const { originCountry, destinationCountry } = useMemo(() => { switch (operationType) { @@ -46,14 +41,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -96,22 +83,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -173,94 +144,6 @@ export function Step4Route({ )} - - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract - should cover. - - - {extraRoutes.map((rf, i) => { - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 8f53b1b64..bbb262724 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -250,10 +250,6 @@ export function Step8Review({ ? direction.charAt(0) + direction.slice(1).toLowerCase() : "—"; - const routesCount = 1 + (values.extraRoutes?.filter( - (r) => r.originYard && r.destinationYard, - ).length ?? 0); - return ( } - label="Primary route" + label="Route" value={`${originYardName} → ${destinationYardName}`} /> } label="Trade direction" - value={ - isGeneralContract - ? `${directionLabel} · ${routesCount} routes` - : directionLabel - } + value={directionLabel} /> } diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index f0a25158d..8561f3721 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -20,3 +20,6 @@ export * from "./repositories/base.repository"; // Services export * from "./services/exchange"; + +// Utils +export * from "./utils/reference-sequence"; diff --git a/packages/api-common/src/utils/reference-sequence.ts b/packages/api-common/src/utils/reference-sequence.ts new file mode 100644 index 000000000..ecdc3e18f --- /dev/null +++ b/packages/api-common/src/utils/reference-sequence.ts @@ -0,0 +1,50 @@ +import { QueryFailedError } from "typeorm"; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * True when `error` is a Postgres unique-constraint violation. Used to detect a + * reference-number collision from a concurrent insert so the caller can retry + * with a freshly-computed number instead of surfacing a 500. + */ +export function isUniqueViolation(error: unknown): boolean { + if (!(error instanceof QueryFailedError)) return false; + const driver = ( + error as QueryFailedError & { driverError?: { code?: string } } + ).driverError; + return driver?.code === PG_UNIQUE_VIOLATION; +} + +/** + * Run `insert(reference)` under a "generate → try → retry on collision" loop. + * + * `MAX(sequence) + 1` alone is not concurrency-safe: two requests can read the + * same max and derive the same reference, and one insert then hits the unique + * index. On that collision we recompute the reference and try again, so the + * sequence advances under load instead of throwing. Non-collision errors (and + * exhausting the attempt budget) propagate unchanged. + * + * @param generate Async producer of the next reference (e.g. `CTR-2026-00033`). + * Re-invoked on each attempt so it re-reads the current max. + * @param insert Performs the insert with the given reference; its result is + * returned on success. + * @param attempts Maximum tries before giving up (default 5). + */ +export async function insertWithGeneratedReference( + generate: () => Promise, + insert: (reference: string) => Promise, + attempts = 5, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + const reference = await generate(); + try { + return await insert(reference); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + lastError = error; + } + } + throw lastError; +}